+672
-2
api/tangled/cbor_gen.go
+672
-2
api/tangled/cbor_gen.go
···
3923
3923
}
3924
3924
3925
3925
cw := cbg.NewCborWriter(w)
3926
-
fieldCount := 3
3926
+
fieldCount := 4
3927
3927
3928
3928
if t.Environment == nil {
3929
+
fieldCount--
3930
+
}
3931
+
3932
+
if t.Oidcs_tokens == nil {
3929
3933
fieldCount--
3930
3934
}
3931
3935
···
4007
4011
4008
4012
}
4009
4013
}
4014
+
4015
+
// t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice)
4016
+
if t.Oidcs_tokens != nil {
4017
+
4018
+
if len("oidcs_tokens") > 1000000 {
4019
+
return xerrors.Errorf("Value in field \"oidcs_tokens\" was too long")
4020
+
}
4021
+
4022
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("oidcs_tokens"))); err != nil {
4023
+
return err
4024
+
}
4025
+
if _, err := cw.WriteString(string("oidcs_tokens")); err != nil {
4026
+
return err
4027
+
}
4028
+
4029
+
if len(t.Oidcs_tokens) > 8192 {
4030
+
return xerrors.Errorf("Slice value in field t.Oidcs_tokens was too long")
4031
+
}
4032
+
4033
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Oidcs_tokens))); err != nil {
4034
+
return err
4035
+
}
4036
+
for _, v := range t.Oidcs_tokens {
4037
+
if err := v.MarshalCBOR(cw); err != nil {
4038
+
return err
4039
+
}
4040
+
4041
+
}
4042
+
}
4010
4043
return nil
4011
4044
}
4012
4045
···
4035
4068
4036
4069
n := extra
4037
4070
4038
-
nameBuf := make([]byte, 11)
4071
+
nameBuf := make([]byte, 12)
4039
4072
for i := uint64(0); i < n; i++ {
4040
4073
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4041
4074
if err != nil {
···
4121
4154
}
4122
4155
4123
4156
}
4157
+
}
4158
+
// t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice)
4159
+
case "oidcs_tokens":
4160
+
4161
+
maj, extra, err = cr.ReadHeader()
4162
+
if err != nil {
4163
+
return err
4164
+
}
4165
+
4166
+
if extra > 8192 {
4167
+
return fmt.Errorf("t.Oidcs_tokens: array too large (%d)", extra)
4168
+
}
4169
+
4170
+
if maj != cbg.MajArray {
4171
+
return fmt.Errorf("expected cbor array")
4172
+
}
4173
+
4174
+
if extra > 0 {
4175
+
t.Oidcs_tokens = make([]*Pipeline_Step_Oidcs_tokens_Elem, extra)
4176
+
}
4177
+
4178
+
for i := 0; i < int(extra); i++ {
4179
+
{
4180
+
var maj byte
4181
+
var extra uint64
4182
+
var err error
4183
+
_ = maj
4184
+
_ = extra
4185
+
_ = err
4186
+
4187
+
{
4188
+
4189
+
b, err := cr.ReadByte()
4190
+
if err != nil {
4191
+
return err
4192
+
}
4193
+
if b != cbg.CborNull[0] {
4194
+
if err := cr.UnreadByte(); err != nil {
4195
+
return err
4196
+
}
4197
+
t.Oidcs_tokens[i] = new(Pipeline_Step_Oidcs_tokens_Elem)
4198
+
if err := t.Oidcs_tokens[i].UnmarshalCBOR(cr); err != nil {
4199
+
return xerrors.Errorf("unmarshaling t.Oidcs_tokens[i] pointer: %w", err)
4200
+
}
4201
+
}
4202
+
4203
+
}
4204
+
4205
+
}
4206
+
}
4207
+
4208
+
default:
4209
+
// Field doesn't exist on this type, so ignore it
4210
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
4211
+
return err
4212
+
}
4213
+
}
4214
+
}
4215
+
4216
+
return nil
4217
+
}
4218
+
func (t *Pipeline_Step_Oidcs_tokens_Elem) MarshalCBOR(w io.Writer) error {
4219
+
if t == nil {
4220
+
_, err := w.Write(cbg.CborNull)
4221
+
return err
4222
+
}
4223
+
4224
+
cw := cbg.NewCborWriter(w)
4225
+
fieldCount := 2
4226
+
4227
+
if t.Aud == nil {
4228
+
fieldCount--
4229
+
}
4230
+
4231
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
4232
+
return err
4233
+
}
4234
+
4235
+
// t.Aud (string) (string)
4236
+
if t.Aud != nil {
4237
+
4238
+
if len("aud") > 1000000 {
4239
+
return xerrors.Errorf("Value in field \"aud\" was too long")
4240
+
}
4241
+
4242
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("aud"))); err != nil {
4243
+
return err
4244
+
}
4245
+
if _, err := cw.WriteString(string("aud")); err != nil {
4246
+
return err
4247
+
}
4248
+
4249
+
if t.Aud == nil {
4250
+
if _, err := cw.Write(cbg.CborNull); err != nil {
4251
+
return err
4252
+
}
4253
+
} else {
4254
+
if len(*t.Aud) > 1000000 {
4255
+
return xerrors.Errorf("Value in field t.Aud was too long")
4256
+
}
4257
+
4258
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Aud))); err != nil {
4259
+
return err
4260
+
}
4261
+
if _, err := cw.WriteString(string(*t.Aud)); err != nil {
4262
+
return err
4263
+
}
4264
+
}
4265
+
}
4266
+
4267
+
// t.Name (string) (string)
4268
+
if len("name") > 1000000 {
4269
+
return xerrors.Errorf("Value in field \"name\" was too long")
4270
+
}
4271
+
4272
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
4273
+
return err
4274
+
}
4275
+
if _, err := cw.WriteString(string("name")); err != nil {
4276
+
return err
4277
+
}
4278
+
4279
+
if len(t.Name) > 1000000 {
4280
+
return xerrors.Errorf("Value in field t.Name was too long")
4281
+
}
4282
+
4283
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
4284
+
return err
4285
+
}
4286
+
if _, err := cw.WriteString(string(t.Name)); err != nil {
4287
+
return err
4288
+
}
4289
+
return nil
4290
+
}
4291
+
4292
+
func (t *Pipeline_Step_Oidcs_tokens_Elem) UnmarshalCBOR(r io.Reader) (err error) {
4293
+
*t = Pipeline_Step_Oidcs_tokens_Elem{}
4294
+
4295
+
cr := cbg.NewCborReader(r)
4296
+
4297
+
maj, extra, err := cr.ReadHeader()
4298
+
if err != nil {
4299
+
return err
4300
+
}
4301
+
defer func() {
4302
+
if err == io.EOF {
4303
+
err = io.ErrUnexpectedEOF
4304
+
}
4305
+
}()
4306
+
4307
+
if maj != cbg.MajMap {
4308
+
return fmt.Errorf("cbor input should be of type map")
4309
+
}
4310
+
4311
+
if extra > cbg.MaxLength {
4312
+
return fmt.Errorf("Pipeline_Step_Oidcs_tokens_Elem: map struct too large (%d)", extra)
4313
+
}
4314
+
4315
+
n := extra
4316
+
4317
+
nameBuf := make([]byte, 4)
4318
+
for i := uint64(0); i < n; i++ {
4319
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4320
+
if err != nil {
4321
+
return err
4322
+
}
4323
+
4324
+
if !ok {
4325
+
// Field doesn't exist on this type, so ignore it
4326
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
4327
+
return err
4328
+
}
4329
+
continue
4330
+
}
4331
+
4332
+
switch string(nameBuf[:nameLen]) {
4333
+
// t.Aud (string) (string)
4334
+
case "aud":
4335
+
4336
+
{
4337
+
b, err := cr.ReadByte()
4338
+
if err != nil {
4339
+
return err
4340
+
}
4341
+
if b != cbg.CborNull[0] {
4342
+
if err := cr.UnreadByte(); err != nil {
4343
+
return err
4344
+
}
4345
+
4346
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4347
+
if err != nil {
4348
+
return err
4349
+
}
4350
+
4351
+
t.Aud = (*string)(&sval)
4352
+
}
4353
+
}
4354
+
// t.Name (string) (string)
4355
+
case "name":
4356
+
4357
+
{
4358
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4359
+
if err != nil {
4360
+
return err
4361
+
}
4362
+
4363
+
t.Name = string(sval)
4124
4364
}
4125
4365
4126
4366
default:
···
5854
6094
5855
6095
return nil
5856
6096
}
6097
+
func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error {
6098
+
if t == nil {
6099
+
_, err := w.Write(cbg.CborNull)
6100
+
return err
6101
+
}
6102
+
6103
+
cw := cbg.NewCborWriter(w)
6104
+
6105
+
if _, err := cw.Write([]byte{164}); err != nil {
6106
+
return err
6107
+
}
6108
+
6109
+
// t.Repo (string) (string)
6110
+
if len("repo") > 1000000 {
6111
+
return xerrors.Errorf("Value in field \"repo\" was too long")
6112
+
}
6113
+
6114
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
6115
+
return err
6116
+
}
6117
+
if _, err := cw.WriteString(string("repo")); err != nil {
6118
+
return err
6119
+
}
6120
+
6121
+
if len(t.Repo) > 1000000 {
6122
+
return xerrors.Errorf("Value in field t.Repo was too long")
6123
+
}
6124
+
6125
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
6126
+
return err
6127
+
}
6128
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
6129
+
return err
6130
+
}
6131
+
6132
+
// t.LexiconTypeID (string) (string)
6133
+
if len("$type") > 1000000 {
6134
+
return xerrors.Errorf("Value in field \"$type\" was too long")
6135
+
}
6136
+
6137
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
6138
+
return err
6139
+
}
6140
+
if _, err := cw.WriteString(string("$type")); err != nil {
6141
+
return err
6142
+
}
6143
+
6144
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil {
6145
+
return err
6146
+
}
6147
+
if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil {
6148
+
return err
6149
+
}
6150
+
6151
+
// t.Subject (string) (string)
6152
+
if len("subject") > 1000000 {
6153
+
return xerrors.Errorf("Value in field \"subject\" was too long")
6154
+
}
6155
+
6156
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
6157
+
return err
6158
+
}
6159
+
if _, err := cw.WriteString(string("subject")); err != nil {
6160
+
return err
6161
+
}
6162
+
6163
+
if len(t.Subject) > 1000000 {
6164
+
return xerrors.Errorf("Value in field t.Subject was too long")
6165
+
}
6166
+
6167
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
6168
+
return err
6169
+
}
6170
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
6171
+
return err
6172
+
}
6173
+
6174
+
// t.CreatedAt (string) (string)
6175
+
if len("createdAt") > 1000000 {
6176
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
6177
+
}
6178
+
6179
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
6180
+
return err
6181
+
}
6182
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
6183
+
return err
6184
+
}
6185
+
6186
+
if len(t.CreatedAt) > 1000000 {
6187
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
6188
+
}
6189
+
6190
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
6191
+
return err
6192
+
}
6193
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
6194
+
return err
6195
+
}
6196
+
return nil
6197
+
}
6198
+
6199
+
func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) {
6200
+
*t = RepoCollaborator{}
6201
+
6202
+
cr := cbg.NewCborReader(r)
6203
+
6204
+
maj, extra, err := cr.ReadHeader()
6205
+
if err != nil {
6206
+
return err
6207
+
}
6208
+
defer func() {
6209
+
if err == io.EOF {
6210
+
err = io.ErrUnexpectedEOF
6211
+
}
6212
+
}()
6213
+
6214
+
if maj != cbg.MajMap {
6215
+
return fmt.Errorf("cbor input should be of type map")
6216
+
}
6217
+
6218
+
if extra > cbg.MaxLength {
6219
+
return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra)
6220
+
}
6221
+
6222
+
n := extra
6223
+
6224
+
nameBuf := make([]byte, 9)
6225
+
for i := uint64(0); i < n; i++ {
6226
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
6227
+
if err != nil {
6228
+
return err
6229
+
}
6230
+
6231
+
if !ok {
6232
+
// Field doesn't exist on this type, so ignore it
6233
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
6234
+
return err
6235
+
}
6236
+
continue
6237
+
}
6238
+
6239
+
switch string(nameBuf[:nameLen]) {
6240
+
// t.Repo (string) (string)
6241
+
case "repo":
6242
+
6243
+
{
6244
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6245
+
if err != nil {
6246
+
return err
6247
+
}
6248
+
6249
+
t.Repo = string(sval)
6250
+
}
6251
+
// t.LexiconTypeID (string) (string)
6252
+
case "$type":
6253
+
6254
+
{
6255
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6256
+
if err != nil {
6257
+
return err
6258
+
}
6259
+
6260
+
t.LexiconTypeID = string(sval)
6261
+
}
6262
+
// t.Subject (string) (string)
6263
+
case "subject":
6264
+
6265
+
{
6266
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6267
+
if err != nil {
6268
+
return err
6269
+
}
6270
+
6271
+
t.Subject = string(sval)
6272
+
}
6273
+
// t.CreatedAt (string) (string)
6274
+
case "createdAt":
6275
+
6276
+
{
6277
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6278
+
if err != nil {
6279
+
return err
6280
+
}
6281
+
6282
+
t.CreatedAt = string(sval)
6283
+
}
6284
+
6285
+
default:
6286
+
// Field doesn't exist on this type, so ignore it
6287
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
6288
+
return err
6289
+
}
6290
+
}
6291
+
}
6292
+
6293
+
return nil
6294
+
}
5857
6295
func (t *RepoIssue) MarshalCBOR(w io.Writer) error {
5858
6296
if t == nil {
5859
6297
_, err := w.Write(cbg.CborNull)
···
8225
8663
8226
8664
return nil
8227
8665
}
8666
+
func (t *String) MarshalCBOR(w io.Writer) error {
8667
+
if t == nil {
8668
+
_, err := w.Write(cbg.CborNull)
8669
+
return err
8670
+
}
8671
+
8672
+
cw := cbg.NewCborWriter(w)
8673
+
8674
+
if _, err := cw.Write([]byte{165}); err != nil {
8675
+
return err
8676
+
}
8677
+
8678
+
// t.LexiconTypeID (string) (string)
8679
+
if len("$type") > 1000000 {
8680
+
return xerrors.Errorf("Value in field \"$type\" was too long")
8681
+
}
8682
+
8683
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
8684
+
return err
8685
+
}
8686
+
if _, err := cw.WriteString(string("$type")); err != nil {
8687
+
return err
8688
+
}
8689
+
8690
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil {
8691
+
return err
8692
+
}
8693
+
if _, err := cw.WriteString(string("sh.tangled.string")); err != nil {
8694
+
return err
8695
+
}
8696
+
8697
+
// t.Contents (string) (string)
8698
+
if len("contents") > 1000000 {
8699
+
return xerrors.Errorf("Value in field \"contents\" was too long")
8700
+
}
8701
+
8702
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil {
8703
+
return err
8704
+
}
8705
+
if _, err := cw.WriteString(string("contents")); err != nil {
8706
+
return err
8707
+
}
8708
+
8709
+
if len(t.Contents) > 1000000 {
8710
+
return xerrors.Errorf("Value in field t.Contents was too long")
8711
+
}
8712
+
8713
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil {
8714
+
return err
8715
+
}
8716
+
if _, err := cw.WriteString(string(t.Contents)); err != nil {
8717
+
return err
8718
+
}
8719
+
8720
+
// t.Filename (string) (string)
8721
+
if len("filename") > 1000000 {
8722
+
return xerrors.Errorf("Value in field \"filename\" was too long")
8723
+
}
8724
+
8725
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil {
8726
+
return err
8727
+
}
8728
+
if _, err := cw.WriteString(string("filename")); err != nil {
8729
+
return err
8730
+
}
8731
+
8732
+
if len(t.Filename) > 1000000 {
8733
+
return xerrors.Errorf("Value in field t.Filename was too long")
8734
+
}
8735
+
8736
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil {
8737
+
return err
8738
+
}
8739
+
if _, err := cw.WriteString(string(t.Filename)); err != nil {
8740
+
return err
8741
+
}
8742
+
8743
+
// t.CreatedAt (string) (string)
8744
+
if len("createdAt") > 1000000 {
8745
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
8746
+
}
8747
+
8748
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
8749
+
return err
8750
+
}
8751
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
8752
+
return err
8753
+
}
8754
+
8755
+
if len(t.CreatedAt) > 1000000 {
8756
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
8757
+
}
8758
+
8759
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
8760
+
return err
8761
+
}
8762
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
8763
+
return err
8764
+
}
8765
+
8766
+
// t.Description (string) (string)
8767
+
if len("description") > 1000000 {
8768
+
return xerrors.Errorf("Value in field \"description\" was too long")
8769
+
}
8770
+
8771
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
8772
+
return err
8773
+
}
8774
+
if _, err := cw.WriteString(string("description")); err != nil {
8775
+
return err
8776
+
}
8777
+
8778
+
if len(t.Description) > 1000000 {
8779
+
return xerrors.Errorf("Value in field t.Description was too long")
8780
+
}
8781
+
8782
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil {
8783
+
return err
8784
+
}
8785
+
if _, err := cw.WriteString(string(t.Description)); err != nil {
8786
+
return err
8787
+
}
8788
+
return nil
8789
+
}
8790
+
8791
+
func (t *String) UnmarshalCBOR(r io.Reader) (err error) {
8792
+
*t = String{}
8793
+
8794
+
cr := cbg.NewCborReader(r)
8795
+
8796
+
maj, extra, err := cr.ReadHeader()
8797
+
if err != nil {
8798
+
return err
8799
+
}
8800
+
defer func() {
8801
+
if err == io.EOF {
8802
+
err = io.ErrUnexpectedEOF
8803
+
}
8804
+
}()
8805
+
8806
+
if maj != cbg.MajMap {
8807
+
return fmt.Errorf("cbor input should be of type map")
8808
+
}
8809
+
8810
+
if extra > cbg.MaxLength {
8811
+
return fmt.Errorf("String: map struct too large (%d)", extra)
8812
+
}
8813
+
8814
+
n := extra
8815
+
8816
+
nameBuf := make([]byte, 11)
8817
+
for i := uint64(0); i < n; i++ {
8818
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
8819
+
if err != nil {
8820
+
return err
8821
+
}
8822
+
8823
+
if !ok {
8824
+
// Field doesn't exist on this type, so ignore it
8825
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
8826
+
return err
8827
+
}
8828
+
continue
8829
+
}
8830
+
8831
+
switch string(nameBuf[:nameLen]) {
8832
+
// t.LexiconTypeID (string) (string)
8833
+
case "$type":
8834
+
8835
+
{
8836
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8837
+
if err != nil {
8838
+
return err
8839
+
}
8840
+
8841
+
t.LexiconTypeID = string(sval)
8842
+
}
8843
+
// t.Contents (string) (string)
8844
+
case "contents":
8845
+
8846
+
{
8847
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8848
+
if err != nil {
8849
+
return err
8850
+
}
8851
+
8852
+
t.Contents = string(sval)
8853
+
}
8854
+
// t.Filename (string) (string)
8855
+
case "filename":
8856
+
8857
+
{
8858
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8859
+
if err != nil {
8860
+
return err
8861
+
}
8862
+
8863
+
t.Filename = string(sval)
8864
+
}
8865
+
// t.CreatedAt (string) (string)
8866
+
case "createdAt":
8867
+
8868
+
{
8869
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8870
+
if err != nil {
8871
+
return err
8872
+
}
8873
+
8874
+
t.CreatedAt = string(sval)
8875
+
}
8876
+
// t.Description (string) (string)
8877
+
case "description":
8878
+
8879
+
{
8880
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8881
+
if err != nil {
8882
+
return err
8883
+
}
8884
+
8885
+
t.Description = string(sval)
8886
+
}
8887
+
8888
+
default:
8889
+
// Field doesn't exist on this type, so ignore it
8890
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
8891
+
return err
8892
+
}
8893
+
}
8894
+
}
8895
+
8896
+
return nil
8897
+
}
+25
api/tangled/repocollaborator.go
+25
api/tangled/repocollaborator.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.collaborator
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
RepoCollaboratorNSID = "sh.tangled.repo.collaborator"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{})
17
+
} //
18
+
// RECORDTYPE: RepoCollaborator
19
+
type RepoCollaborator struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
// repo: repo to add this user to
23
+
Repo string `json:"repo" cborgen:"repo"`
24
+
Subject string `json:"subject" cborgen:"subject"`
25
+
}
+9
-3
api/tangled/tangledpipeline.go
+9
-3
api/tangled/tangledpipeline.go
···
63
63
64
64
// Pipeline_Step is a "step" in the sh.tangled.pipeline schema.
65
65
type Pipeline_Step struct {
66
-
Command string `json:"command" cborgen:"command"`
67
-
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
68
-
Name string `json:"name" cborgen:"name"`
66
+
Command string `json:"command" cborgen:"command"`
67
+
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
68
+
Name string `json:"name" cborgen:"name"`
69
+
Oidcs_tokens []*Pipeline_Step_Oidcs_tokens_Elem `json:"oidcs_tokens,omitempty" cborgen:"oidcs_tokens,omitempty"`
70
+
}
71
+
72
+
type Pipeline_Step_Oidcs_tokens_Elem struct {
73
+
Aud *string `json:"aud,omitempty" cborgen:"aud,omitempty"`
74
+
Name string `json:"name" cborgen:"name"`
69
75
}
70
76
71
77
// Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
+25
api/tangled/tangledstring.go
+25
api/tangled/tangledstring.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.string
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
StringNSID = "sh.tangled.string"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.string", &String{})
17
+
} //
18
+
// RECORDTYPE: String
19
+
type String struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"`
21
+
Contents string `json:"contents" cborgen:"contents"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Description string `json:"description" cborgen:"description"`
24
+
Filename string `json:"filename" cborgen:"filename"`
25
+
}
+21
-5
appview/config/config.go
+21
-5
appview/config/config.go
···
10
10
)
11
11
12
12
type CoreConfig struct {
13
-
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
-
DbPath string `env:"DB_PATH, default=appview.db"`
15
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
-
Dev bool `env:"DEV, default=false"`
13
+
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
+
DbPath string `env:"DB_PATH, default=appview.db"`
15
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
+
Dev bool `env:"DEV, default=false"`
18
+
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
+
20
+
// temporarily, to add users to default spindle
21
+
AppPassword string `env:"APP_PASSWORD"`
18
22
}
19
23
20
24
type OAuthConfig struct {
···
59
63
DB int `env:"DB, default=0"`
60
64
}
61
65
66
+
type PdsConfig struct {
67
+
Host string `env:"HOST, default=https://tngl.sh"`
68
+
AdminSecret string `env:"ADMIN_SECRET"`
69
+
}
70
+
71
+
type Cloudflare struct {
72
+
ApiToken string `env:"API_TOKEN"`
73
+
ZoneId string `env:"ZONE_ID"`
74
+
}
75
+
62
76
func (cfg RedisConfig) ToURL() string {
63
77
u := &url.URL{
64
78
Scheme: "redis",
···
84
98
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
99
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
100
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
101
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
102
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
87
103
}
88
104
89
105
func LoadConfig(ctx context.Context) (*Config, error) {
+76
appview/db/collaborators.go
+76
appview/db/collaborators.go
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
)
10
+
11
+
type Collaborator struct {
12
+
// identifiers for the record
13
+
Id int64
14
+
Did syntax.DID
15
+
Rkey string
16
+
17
+
// content
18
+
SubjectDid syntax.DID
19
+
RepoAt syntax.ATURI
20
+
21
+
// meta
22
+
Created time.Time
23
+
}
24
+
25
+
func AddCollaborator(e Execer, c Collaborator) error {
26
+
_, err := e.Exec(
27
+
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
28
+
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
29
+
)
30
+
return err
31
+
}
32
+
33
+
func DeleteCollaborator(e Execer, filters ...filter) error {
34
+
var conditions []string
35
+
var args []any
36
+
for _, filter := range filters {
37
+
conditions = append(conditions, filter.Condition())
38
+
args = append(args, filter.Arg()...)
39
+
}
40
+
41
+
whereClause := ""
42
+
if conditions != nil {
43
+
whereClause = " where " + strings.Join(conditions, " and ")
44
+
}
45
+
46
+
query := fmt.Sprintf(`delete from collaborators %s`, whereClause)
47
+
48
+
_, err := e.Exec(query, args...)
49
+
return err
50
+
}
51
+
52
+
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
53
+
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
defer rows.Close()
58
+
59
+
var repoAts []string
60
+
for rows.Next() {
61
+
var aturi string
62
+
err := rows.Scan(&aturi)
63
+
if err != nil {
64
+
return nil, err
65
+
}
66
+
repoAts = append(repoAts, aturi)
67
+
}
68
+
if err := rows.Err(); err != nil {
69
+
return nil, err
70
+
}
71
+
if repoAts == nil {
72
+
return nil, nil
73
+
}
74
+
75
+
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
76
+
}
+79
-2
appview/db/db.go
+79
-2
appview/db/db.go
···
436
436
unique(repo_at, ref, language)
437
437
);
438
438
439
+
create table if not exists signups_inflight (
440
+
id integer primary key autoincrement,
441
+
email text not null unique,
442
+
invite_code text not null,
443
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
444
+
);
445
+
446
+
create table if not exists strings (
447
+
-- identifiers
448
+
did text not null,
449
+
rkey text not null,
450
+
451
+
-- content
452
+
filename text not null,
453
+
description text,
454
+
content text not null,
455
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
456
+
edited text,
457
+
458
+
primary key (did, rkey)
459
+
);
460
+
439
461
create table if not exists migrations (
440
462
id integer primary key autoincrement,
441
463
name text unique
···
578
600
return nil
579
601
})
580
602
603
+
// recreate and add rkey + created columns with default constraint
604
+
runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error {
605
+
// create new table
606
+
// - repo_at instead of repo integer
607
+
// - rkey field
608
+
// - created field
609
+
_, err := tx.Exec(`
610
+
create table collaborators_new (
611
+
-- identifiers for the record
612
+
id integer primary key autoincrement,
613
+
did text not null,
614
+
rkey text,
615
+
616
+
-- content
617
+
subject_did text not null,
618
+
repo_at text not null,
619
+
620
+
-- meta
621
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
622
+
623
+
-- constraints
624
+
foreign key (repo_at) references repos(at_uri) on delete cascade
625
+
)
626
+
`)
627
+
if err != nil {
628
+
return err
629
+
}
630
+
631
+
// copy data
632
+
_, err = tx.Exec(`
633
+
insert into collaborators_new (id, did, rkey, subject_did, repo_at)
634
+
select
635
+
c.id,
636
+
r.did,
637
+
'',
638
+
c.did,
639
+
r.at_uri
640
+
from collaborators c
641
+
join repos r on c.repo = r.id
642
+
`)
643
+
if err != nil {
644
+
return err
645
+
}
646
+
647
+
// drop old table
648
+
_, err = tx.Exec(`drop table collaborators`)
649
+
if err != nil {
650
+
return err
651
+
}
652
+
653
+
// rename new table
654
+
_, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
655
+
return err
656
+
})
657
+
581
658
return &DB{db}, nil
582
659
}
583
660
···
651
728
kind := rv.Kind()
652
729
653
730
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
654
-
if kind == reflect.Slice || kind == reflect.Array {
731
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
655
732
if rv.Len() == 0 {
656
733
// always false
657
734
return "1 = 0"
···
671
748
func (f filter) Arg() []any {
672
749
rv := reflect.ValueOf(f.arg)
673
750
kind := rv.Kind()
674
-
if kind == reflect.Slice || kind == reflect.Array {
751
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
675
752
if rv.Len() == 0 {
676
753
return nil
677
754
}
+16
-2
appview/db/email.go
+16
-2
appview/db/email.go
···
103
103
query := `
104
104
select email, did
105
105
from emails
106
-
where
107
-
verified = ?
106
+
where
107
+
verified = ?
108
108
and email in (` + strings.Join(placeholders, ",") + `)
109
109
`
110
110
···
153
153
`
154
154
var count int
155
155
err := e.QueryRow(query, did, email).Scan(&count)
156
+
if err != nil {
157
+
return false, err
158
+
}
159
+
return count > 0, nil
160
+
}
161
+
162
+
func CheckEmailExistsAtAll(e Execer, email string) (bool, error) {
163
+
query := `
164
+
select count(*)
165
+
from emails
166
+
where email = ?
167
+
`
168
+
var count int
169
+
err := e.QueryRow(query, email).Scan(&count)
156
170
if err != nil {
157
171
return false, err
158
172
}
+2
-36
appview/db/repos.go
+2
-36
appview/db/repos.go
···
391
391
var description, spindle sql.NullString
392
392
393
393
row := e.QueryRow(`
394
-
select did, name, knot, created, at_uri, description, spindle
394
+
select did, name, knot, created, at_uri, description, spindle
395
395
from repos
396
396
where did = ? and name = ?
397
397
`,
···
550
550
return &repo, nil
551
551
}
552
552
553
-
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
554
-
_, err := e.Exec(
555
-
`insert into collaborators (did, repo)
556
-
values (?, (select id from repos where did = ? and name = ? and knot = ?));`,
557
-
collaborator, repoOwnerDid, repoName, repoKnot)
558
-
return err
559
-
}
560
-
561
553
func UpdateDescription(e Execer, repoAt, newDescription string) error {
562
554
_, err := e.Exec(
563
555
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
564
556
return err
565
557
}
566
558
567
-
func UpdateSpindle(e Execer, repoAt, spindle string) error {
559
+
func UpdateSpindle(e Execer, repoAt string, spindle *string) error {
568
560
_, err := e.Exec(
569
561
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
570
562
return err
571
-
}
572
-
573
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
574
-
rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator)
575
-
if err != nil {
576
-
return nil, err
577
-
}
578
-
defer rows.Close()
579
-
580
-
var repoIds []int
581
-
for rows.Next() {
582
-
var id int
583
-
err := rows.Scan(&id)
584
-
if err != nil {
585
-
return nil, err
586
-
}
587
-
repoIds = append(repoIds, id)
588
-
}
589
-
if err := rows.Err(); err != nil {
590
-
return nil, err
591
-
}
592
-
if repoIds == nil {
593
-
return nil, nil
594
-
}
595
-
596
-
return GetRepos(e, 0, FilterIn("id", repoIds))
597
563
}
598
564
599
565
type RepoStats struct {
+29
appview/db/signup.go
+29
appview/db/signup.go
···
1
+
package db
2
+
3
+
import "time"
4
+
5
+
type InflightSignup struct {
6
+
Id int64
7
+
Email string
8
+
InviteCode string
9
+
Created time.Time
10
+
}
11
+
12
+
func AddInflightSignup(e Execer, signup InflightSignup) error {
13
+
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
+
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
+
return err
16
+
}
17
+
18
+
func DeleteInflightSignup(e Execer, email string) error {
19
+
query := `delete from signups_inflight where email = ?`
20
+
_, err := e.Exec(query, email)
21
+
return err
22
+
}
23
+
24
+
func GetEmailForCode(e Execer, inviteCode string) (string, error) {
25
+
query := `select email from signups_inflight where invite_code = ?`
26
+
var email string
27
+
err := e.QueryRow(query, inviteCode).Scan(&email)
28
+
return email, err
29
+
}
+251
appview/db/strings.go
+251
appview/db/strings.go
···
1
+
package db
2
+
3
+
import (
4
+
"bytes"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"io"
9
+
"strings"
10
+
"time"
11
+
"unicode/utf8"
12
+
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
)
16
+
17
+
type String struct {
18
+
Did syntax.DID
19
+
Rkey string
20
+
21
+
Filename string
22
+
Description string
23
+
Contents string
24
+
Created time.Time
25
+
Edited *time.Time
26
+
}
27
+
28
+
func (s *String) StringAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
+
}
31
+
32
+
type StringStats struct {
33
+
LineCount uint64
34
+
ByteCount uint64
35
+
}
36
+
37
+
func (s String) Stats() StringStats {
38
+
lineCount, err := countLines(strings.NewReader(s.Contents))
39
+
if err != nil {
40
+
// non-fatal
41
+
// TODO: log this?
42
+
}
43
+
44
+
return StringStats{
45
+
LineCount: uint64(lineCount),
46
+
ByteCount: uint64(len(s.Contents)),
47
+
}
48
+
}
49
+
50
+
func (s String) Validate() error {
51
+
var err error
52
+
53
+
if !strings.Contains(s.Filename, ".") {
54
+
err = errors.Join(err, fmt.Errorf("missing filename extension"))
55
+
}
56
+
57
+
if strings.HasSuffix(s.Filename, ".") {
58
+
err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
59
+
}
60
+
61
+
if utf8.RuneCountInString(s.Filename) > 140 {
62
+
err = errors.Join(err, fmt.Errorf("filename too long"))
63
+
}
64
+
65
+
if utf8.RuneCountInString(s.Description) > 280 {
66
+
err = errors.Join(err, fmt.Errorf("description too long"))
67
+
}
68
+
69
+
if len(s.Contents) == 0 {
70
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
71
+
}
72
+
73
+
return err
74
+
}
75
+
76
+
func (s *String) AsRecord() tangled.String {
77
+
return tangled.String{
78
+
Filename: s.Filename,
79
+
Description: s.Description,
80
+
Contents: s.Contents,
81
+
CreatedAt: s.Created.Format(time.RFC3339),
82
+
}
83
+
}
84
+
85
+
func StringFromRecord(did, rkey string, record tangled.String) String {
86
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
87
+
if err != nil {
88
+
created = time.Now()
89
+
}
90
+
return String{
91
+
Did: syntax.DID(did),
92
+
Rkey: rkey,
93
+
Filename: record.Filename,
94
+
Description: record.Description,
95
+
Contents: record.Contents,
96
+
Created: created,
97
+
}
98
+
}
99
+
100
+
func AddString(e Execer, s String) error {
101
+
_, err := e.Exec(
102
+
`insert into strings (
103
+
did,
104
+
rkey,
105
+
filename,
106
+
description,
107
+
content,
108
+
created,
109
+
edited
110
+
)
111
+
values (?, ?, ?, ?, ?, ?, null)
112
+
on conflict(did, rkey) do update set
113
+
filename = excluded.filename,
114
+
description = excluded.description,
115
+
content = excluded.content,
116
+
edited = case
117
+
when
118
+
strings.content != excluded.content
119
+
or strings.filename != excluded.filename
120
+
or strings.description != excluded.description then ?
121
+
else strings.edited
122
+
end`,
123
+
s.Did,
124
+
s.Rkey,
125
+
s.Filename,
126
+
s.Description,
127
+
s.Contents,
128
+
s.Created.Format(time.RFC3339),
129
+
time.Now().Format(time.RFC3339),
130
+
)
131
+
return err
132
+
}
133
+
134
+
func GetStrings(e Execer, filters ...filter) ([]String, error) {
135
+
var all []String
136
+
137
+
var conditions []string
138
+
var args []any
139
+
for _, filter := range filters {
140
+
conditions = append(conditions, filter.Condition())
141
+
args = append(args, filter.Arg()...)
142
+
}
143
+
144
+
whereClause := ""
145
+
if conditions != nil {
146
+
whereClause = " where " + strings.Join(conditions, " and ")
147
+
}
148
+
149
+
query := fmt.Sprintf(`select
150
+
did,
151
+
rkey,
152
+
filename,
153
+
description,
154
+
content,
155
+
created,
156
+
edited
157
+
from strings %s`,
158
+
whereClause,
159
+
)
160
+
161
+
rows, err := e.Query(query, args...)
162
+
163
+
if err != nil {
164
+
return nil, err
165
+
}
166
+
defer rows.Close()
167
+
168
+
for rows.Next() {
169
+
var s String
170
+
var createdAt string
171
+
var editedAt sql.NullString
172
+
173
+
if err := rows.Scan(
174
+
&s.Did,
175
+
&s.Rkey,
176
+
&s.Filename,
177
+
&s.Description,
178
+
&s.Contents,
179
+
&createdAt,
180
+
&editedAt,
181
+
); err != nil {
182
+
return nil, err
183
+
}
184
+
185
+
s.Created, err = time.Parse(time.RFC3339, createdAt)
186
+
if err != nil {
187
+
s.Created = time.Now()
188
+
}
189
+
190
+
if editedAt.Valid {
191
+
e, err := time.Parse(time.RFC3339, editedAt.String)
192
+
if err != nil {
193
+
e = time.Now()
194
+
}
195
+
s.Edited = &e
196
+
}
197
+
198
+
all = append(all, s)
199
+
}
200
+
201
+
if err := rows.Err(); err != nil {
202
+
return nil, err
203
+
}
204
+
205
+
return all, nil
206
+
}
207
+
208
+
func DeleteString(e Execer, filters ...filter) error {
209
+
var conditions []string
210
+
var args []any
211
+
for _, filter := range filters {
212
+
conditions = append(conditions, filter.Condition())
213
+
args = append(args, filter.Arg()...)
214
+
}
215
+
216
+
whereClause := ""
217
+
if conditions != nil {
218
+
whereClause = " where " + strings.Join(conditions, " and ")
219
+
}
220
+
221
+
query := fmt.Sprintf(`delete from strings %s`, whereClause)
222
+
223
+
_, err := e.Exec(query, args...)
224
+
return err
225
+
}
226
+
227
+
func countLines(r io.Reader) (int, error) {
228
+
buf := make([]byte, 32*1024)
229
+
bufLen := 0
230
+
count := 0
231
+
nl := []byte{'\n'}
232
+
233
+
for {
234
+
c, err := r.Read(buf)
235
+
if c > 0 {
236
+
bufLen += c
237
+
}
238
+
count += bytes.Count(buf[:c], nl)
239
+
240
+
switch {
241
+
case err == io.EOF:
242
+
/* handle last line not having a newline at the end */
243
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
244
+
count++
245
+
}
246
+
return count, nil
247
+
case err != nil:
248
+
return 0, err
249
+
}
250
+
}
251
+
}
+53
appview/dns/cloudflare.go
+53
appview/dns/cloudflare.go
···
1
+
package dns
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
7
+
"github.com/cloudflare/cloudflare-go"
8
+
"tangled.sh/tangled.sh/core/appview/config"
9
+
)
10
+
11
+
type Record struct {
12
+
Type string
13
+
Name string
14
+
Content string
15
+
TTL int
16
+
Proxied bool
17
+
}
18
+
19
+
type Cloudflare struct {
20
+
api *cloudflare.API
21
+
zone string
22
+
}
23
+
24
+
func NewCloudflare(c *config.Config) (*Cloudflare, error) {
25
+
apiToken := c.Cloudflare.ApiToken
26
+
api, err := cloudflare.NewWithAPIToken(apiToken)
27
+
if err != nil {
28
+
return nil, err
29
+
}
30
+
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
+
}
32
+
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
+
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
+
Type: record.Type,
36
+
Name: record.Name,
37
+
Content: record.Content,
38
+
TTL: record.TTL,
39
+
Proxied: &record.Proxied,
40
+
})
41
+
if err != nil {
42
+
return fmt.Errorf("failed to create DNS record: %w", err)
43
+
}
44
+
return nil
45
+
}
46
+
47
+
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
48
+
err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID)
49
+
if err != nil {
50
+
return fmt.Errorf("failed to delete DNS record: %w", err)
51
+
}
52
+
return nil
53
+
}
+60
appview/ingester.go
+60
appview/ingester.go
···
64
64
err = i.ingestSpindleMember(e)
65
65
case tangled.SpindleNSID:
66
66
err = i.ingestSpindle(e)
67
+
case tangled.StringNSID:
68
+
err = i.ingestString(e)
67
69
}
68
70
l = i.Logger.With("nsid", e.Commit.Collection)
69
71
}
···
385
387
if err != nil {
386
388
return fmt.Errorf("failed to update ACLs: %w", err)
387
389
}
390
+
391
+
l.Info("added spindle member")
388
392
case models.CommitOperationDelete:
389
393
rkey := e.Commit.RKey
390
394
···
431
435
if err = i.Enforcer.E.SavePolicy(); err != nil {
432
436
return fmt.Errorf("failed to save ACLs: %w", err)
433
437
}
438
+
439
+
l.Info("removed spindle member")
434
440
}
435
441
436
442
return nil
···
549
555
550
556
return nil
551
557
}
558
+
559
+
func (i *Ingester) ingestString(e *models.Event) error {
560
+
did := e.Did
561
+
rkey := e.Commit.RKey
562
+
563
+
var err error
564
+
565
+
l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
566
+
l.Info("ingesting record")
567
+
568
+
ddb, ok := i.Db.Execer.(*db.DB)
569
+
if !ok {
570
+
return fmt.Errorf("failed to index string record, invalid db cast")
571
+
}
572
+
573
+
switch e.Commit.Operation {
574
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
575
+
raw := json.RawMessage(e.Commit.Record)
576
+
record := tangled.String{}
577
+
err = json.Unmarshal(raw, &record)
578
+
if err != nil {
579
+
l.Error("invalid record", "err", err)
580
+
return err
581
+
}
582
+
583
+
string := db.StringFromRecord(did, rkey, record)
584
+
585
+
if err = string.Validate(); err != nil {
586
+
l.Error("invalid record", "err", err)
587
+
return err
588
+
}
589
+
590
+
if err = db.AddString(ddb, string); err != nil {
591
+
l.Error("failed to add string", "err", err)
592
+
return err
593
+
}
594
+
595
+
return nil
596
+
597
+
case models.CommitOperationDelete:
598
+
if err := db.DeleteString(
599
+
ddb,
600
+
db.FilterEq("did", did),
601
+
db.FilterEq("rkey", rkey),
602
+
); err != nil {
603
+
l.Error("failed to delete", "err", err)
604
+
return fmt.Errorf("failed to delete string record: %w", err)
605
+
}
606
+
607
+
return nil
608
+
}
609
+
610
+
return nil
611
+
}
+2
-10
appview/middleware/middleware.go
+2
-10
appview/middleware/middleware.go
···
167
167
}
168
168
}
169
169
170
-
func StripLeadingAt(next http.Handler) http.Handler {
171
-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
172
-
path := req.URL.EscapedPath()
173
-
if strings.HasPrefix(path, "/@") {
174
-
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
175
-
}
176
-
next.ServeHTTP(w, req)
177
-
})
178
-
}
179
-
180
170
func (mw Middleware) ResolveIdent() middlewareFunc {
181
171
excluded := []string{"favicon.ico"}
182
172
···
187
177
next.ServeHTTP(w, req)
188
178
return
189
179
}
180
+
181
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
190
182
191
183
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
184
if err != nil {
+141
appview/oauth/handler/handler.go
+141
appview/oauth/handler/handler.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"bytes"
5
+
"context"
4
6
"encoding/json"
5
7
"fmt"
6
8
"log"
7
9
"net/http"
8
10
"net/url"
9
11
"strings"
12
+
"time"
10
13
11
14
"github.com/go-chi/chi/v5"
12
15
"github.com/gorilla/sessions"
13
16
"github.com/lestrrat-go/jwx/v2/jwk"
14
17
"github.com/posthog/posthog-go"
15
18
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
19
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
16
20
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
21
"tangled.sh/tangled.sh/core/appview/config"
18
22
"tangled.sh/tangled.sh/core/appview/db"
···
23
27
"tangled.sh/tangled.sh/core/idresolver"
24
28
"tangled.sh/tangled.sh/core/knotclient"
25
29
"tangled.sh/tangled.sh/core/rbac"
30
+
"tangled.sh/tangled.sh/core/tid"
26
31
)
27
32
28
33
const (
···
294
299
295
300
log.Println("session saved successfully")
296
301
go o.addToDefaultKnot(oauthRequest.Did)
302
+
go o.addToDefaultSpindle(oauthRequest.Did)
297
303
298
304
if !o.config.Core.Dev {
299
305
err = o.posthog.Enqueue(posthog.Capture{
···
330
336
return nil, err
331
337
}
332
338
return pubKey, nil
339
+
}
340
+
341
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
342
+
// use the tangled.sh app password to get an accessJwt
343
+
// and create an sh.tangled.spindle.member record with that
344
+
345
+
defaultSpindle := "spindle.tangled.sh"
346
+
appPassword := o.config.Core.AppPassword
347
+
348
+
spindleMembers, err := db.GetSpindleMembers(
349
+
o.db,
350
+
db.FilterEq("instance", "spindle.tangled.sh"),
351
+
db.FilterEq("subject", did),
352
+
)
353
+
if err != nil {
354
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
355
+
return
356
+
}
357
+
358
+
if len(spindleMembers) != 0 {
359
+
log.Printf("did %s is already a member of the default spindle", did)
360
+
return
361
+
}
362
+
363
+
// TODO: hardcoded tangled handle and did for now
364
+
tangledHandle := "tangled.sh"
365
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
366
+
367
+
if appPassword == "" {
368
+
log.Println("no app password configured, skipping spindle member addition")
369
+
return
370
+
}
371
+
372
+
log.Printf("adding %s to default spindle", did)
373
+
374
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
375
+
if err != nil {
376
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
377
+
return
378
+
}
379
+
380
+
pdsEndpoint := resolved.PDSEndpoint()
381
+
if pdsEndpoint == "" {
382
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
383
+
return
384
+
}
385
+
386
+
sessionPayload := map[string]string{
387
+
"identifier": tangledHandle,
388
+
"password": appPassword,
389
+
}
390
+
sessionBytes, err := json.Marshal(sessionPayload)
391
+
if err != nil {
392
+
log.Printf("failed to marshal session payload: %v", err)
393
+
return
394
+
}
395
+
396
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
397
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
398
+
if err != nil {
399
+
log.Printf("failed to create session request: %v", err)
400
+
return
401
+
}
402
+
sessionReq.Header.Set("Content-Type", "application/json")
403
+
404
+
client := &http.Client{Timeout: 30 * time.Second}
405
+
sessionResp, err := client.Do(sessionReq)
406
+
if err != nil {
407
+
log.Printf("failed to create session: %v", err)
408
+
return
409
+
}
410
+
defer sessionResp.Body.Close()
411
+
412
+
if sessionResp.StatusCode != http.StatusOK {
413
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
414
+
return
415
+
}
416
+
417
+
var session struct {
418
+
AccessJwt string `json:"accessJwt"`
419
+
}
420
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
421
+
log.Printf("failed to decode session response: %v", err)
422
+
return
423
+
}
424
+
425
+
record := tangled.SpindleMember{
426
+
LexiconTypeID: "sh.tangled.spindle.member",
427
+
Subject: did,
428
+
Instance: defaultSpindle,
429
+
CreatedAt: time.Now().Format(time.RFC3339),
430
+
}
431
+
432
+
recordBytes, err := json.Marshal(record)
433
+
if err != nil {
434
+
log.Printf("failed to marshal spindle member record: %v", err)
435
+
return
436
+
}
437
+
438
+
payload := map[string]interface{}{
439
+
"repo": tangledDid,
440
+
"collection": tangled.SpindleMemberNSID,
441
+
"rkey": tid.TID(),
442
+
"record": json.RawMessage(recordBytes),
443
+
}
444
+
445
+
payloadBytes, err := json.Marshal(payload)
446
+
if err != nil {
447
+
log.Printf("failed to marshal request payload: %v", err)
448
+
return
449
+
}
450
+
451
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
452
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
453
+
if err != nil {
454
+
log.Printf("failed to create HTTP request: %v", err)
455
+
return
456
+
}
457
+
458
+
req.Header.Set("Content-Type", "application/json")
459
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
460
+
461
+
resp, err := client.Do(req)
462
+
if err != nil {
463
+
log.Printf("failed to add user to default spindle: %v", err)
464
+
return
465
+
}
466
+
defer resp.Body.Close()
467
+
468
+
if resp.StatusCode != http.StatusOK {
469
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
470
+
return
471
+
}
472
+
473
+
log.Printf("successfully added %s to default spindle", did)
333
474
}
334
475
335
476
func (o *OAuthHandler) addToDefaultKnot(did string) {
+98
-1
appview/pages/pages.go
+98
-1
appview/pages/pages.go
···
31
31
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
32
32
"github.com/alecthomas/chroma/v2/lexers"
33
33
"github.com/alecthomas/chroma/v2/styles"
34
+
"github.com/bluesky-social/indigo/atproto/identity"
34
35
"github.com/bluesky-social/indigo/atproto/syntax"
35
36
"github.com/go-git/go-git/v5/plumbing"
36
37
"github.com/go-git/go-git/v5/plumbing/object"
···
262
263
return p.executePlain("user/login", w, params)
263
264
}
264
265
266
+
func (p *Pages) Signup(w io.Writer) error {
267
+
return p.executePlain("user/signup", w, nil)
268
+
}
269
+
270
+
func (p *Pages) CompleteSignup(w io.Writer) error {
271
+
return p.executePlain("user/completeSignup", w, nil)
272
+
}
273
+
274
+
type TermsOfServiceParams struct {
275
+
LoggedInUser *oauth.User
276
+
}
277
+
278
+
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
279
+
return p.execute("legal/terms", w, params)
280
+
}
281
+
282
+
type PrivacyPolicyParams struct {
283
+
LoggedInUser *oauth.User
284
+
}
285
+
286
+
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
287
+
return p.execute("legal/privacy", w, params)
288
+
}
289
+
265
290
type TimelineParams struct {
266
291
LoggedInUser *oauth.User
267
292
Timeline []db.TimelineEvent
···
391
416
UserDid string
392
417
UserHandle string
393
418
FollowStatus db.FollowStatus
394
-
AvatarUri string
395
419
Followers int
396
420
Following int
397
421
···
1118
1142
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1119
1143
params.Active = "pipelines"
1120
1144
return p.executeRepo("repo/pipelines/workflow", w, params)
1145
+
}
1146
+
1147
+
type PutStringParams struct {
1148
+
LoggedInUser *oauth.User
1149
+
Action string
1150
+
1151
+
// this is supplied in the case of editing an existing string
1152
+
String db.String
1153
+
}
1154
+
1155
+
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1156
+
return p.execute("strings/put", w, params)
1157
+
}
1158
+
1159
+
type StringsDashboardParams struct {
1160
+
LoggedInUser *oauth.User
1161
+
Card ProfileCard
1162
+
Strings []db.String
1163
+
}
1164
+
1165
+
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1166
+
return p.execute("strings/dashboard", w, params)
1167
+
}
1168
+
1169
+
type SingleStringParams struct {
1170
+
LoggedInUser *oauth.User
1171
+
ShowRendered bool
1172
+
RenderToggle bool
1173
+
RenderedContents template.HTML
1174
+
String db.String
1175
+
Stats db.StringStats
1176
+
Owner identity.Identity
1177
+
}
1178
+
1179
+
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1180
+
var style *chroma.Style = styles.Get("catpuccin-latte")
1181
+
1182
+
if params.ShowRendered {
1183
+
switch markup.GetFormat(params.String.Filename) {
1184
+
case markup.FormatMarkdown:
1185
+
p.rctx.RendererType = markup.RendererTypeDefault
1186
+
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1187
+
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
1188
+
}
1189
+
}
1190
+
1191
+
c := params.String.Contents
1192
+
formatter := chromahtml.New(
1193
+
chromahtml.InlineCode(false),
1194
+
chromahtml.WithLineNumbers(true),
1195
+
chromahtml.WithLinkableLineNumbers(true, "L"),
1196
+
chromahtml.Standalone(false),
1197
+
chromahtml.WithClasses(true),
1198
+
)
1199
+
1200
+
lexer := lexers.Get(filepath.Base(params.String.Filename))
1201
+
if lexer == nil {
1202
+
lexer = lexers.Fallback
1203
+
}
1204
+
1205
+
iterator, err := lexer.Tokenise(nil, c)
1206
+
if err != nil {
1207
+
return fmt.Errorf("chroma tokenize: %w", err)
1208
+
}
1209
+
1210
+
var code bytes.Buffer
1211
+
err = formatter.Format(&code, style, iterator)
1212
+
if err != nil {
1213
+
return fmt.Errorf("chroma format: %w", err)
1214
+
}
1215
+
1216
+
params.String.Contents = code.String()
1217
+
return p.execute("strings/string", w, params)
1121
1218
}
1122
1219
1123
1220
func (p *Pages) Static() http.Handler {
+25
-16
appview/pages/templates/layouts/topbar.html
+25
-16
appview/pages/templates/layouts/topbar.html
···
6
6
tangled<sub>alpha</sub>
7
7
</a>
8
8
</div>
9
-
<div class="hidden md:flex gap-4 items-center">
10
-
<a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center">
11
-
{{ i "message-circle" "size-4" }} discord
12
-
</a>
13
9
14
-
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
15
-
{{ i "hash" "size-4" }} irc
16
-
</a>
17
-
18
-
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
19
-
{{ i "code" "size-4" }} source
20
-
</a>
21
-
</div>
22
-
<div id="right-items" class="flex items-center gap-4">
10
+
<div id="right-items" class="flex items-center gap-2">
23
11
{{ with .LoggedInUser }}
24
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
25
-
{{ i "plus" "w-4 h-4" }}
26
-
</a>
12
+
{{ block "newButton" . }} {{ end }}
27
13
{{ block "dropDown" . }} {{ end }}
28
14
{{ else }}
29
15
<a href="/login">login</a>
16
+
<span class="text-gray-500 dark:text-gray-400">or</span>
17
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
18
+
join now {{ i "arrow-right" "size-4" }}
19
+
</a>
30
20
{{ end }}
31
21
</div>
32
22
</div>
33
23
</nav>
34
24
{{ end }}
35
25
26
+
{{ define "newButton" }}
27
+
<details class="relative inline-block text-left">
28
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
+
{{ i "plus" "w-4 h-4" }} new
30
+
</summary>
31
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
+
<a href="/repo/new" class="flex items-center gap-2">
33
+
{{ i "book-plus" "w-4 h-4" }}
34
+
new repository
35
+
</a>
36
+
<a href="/strings/new" class="flex items-center gap-2">
37
+
{{ i "line-squiggle" "w-4 h-4" }}
38
+
new string
39
+
</a>
40
+
</div>
41
+
</details>
42
+
{{ end }}
43
+
36
44
{{ define "dropDown" }}
37
45
<details class="relative inline-block text-left">
38
46
<summary
···
46
54
>
47
55
<a href="/{{ $user }}">profile</a>
48
56
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
49
58
<a href="/knots">knots</a>
50
59
<a href="/spindles">spindles</a>
51
60
<a href="/settings">settings</a>
+133
appview/pages/templates/legal/privacy.html
+133
appview/pages/templates/legal/privacy.html
···
1
+
{{ define "title" }} privacy policy {{ end }}
2
+
{{ define "content" }}
3
+
<div class="max-w-4xl mx-auto px-4 py-8">
4
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
5
+
<div class="prose prose-gray dark:prose-invert max-w-none">
6
+
<h1>Privacy Policy</h1>
7
+
8
+
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
9
+
10
+
<p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
11
+
12
+
<h2>1. Information We Collect</h2>
13
+
14
+
<h3>Account Information</h3>
15
+
<p>When you create an account, we collect:</p>
16
+
<ul>
17
+
<li>Your chosen username</li>
18
+
<li>Email address</li>
19
+
<li>Profile information you choose to provide</li>
20
+
<li>Authentication data</li>
21
+
</ul>
22
+
23
+
<h3>Content and Activity</h3>
24
+
<p>We store:</p>
25
+
<ul>
26
+
<li>Code repositories and associated metadata</li>
27
+
<li>Issues, pull requests, and comments</li>
28
+
<li>Activity logs and usage patterns</li>
29
+
<li>Public keys for authentication</li>
30
+
</ul>
31
+
32
+
<h2>2. Data Location and Hosting</h2>
33
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
34
+
<h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
35
+
<p class="text-blue-700 dark:text-blue-300">
36
+
<strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
37
+
</p>
38
+
<ul class="text-blue-700 dark:text-blue-300 mt-2">
39
+
<li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
40
+
<li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
41
+
<li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
42
+
</ul>
43
+
</div>
44
+
45
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
46
+
<h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
47
+
<p class="text-yellow-700 dark:text-yellow-300">
48
+
<strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
49
+
</p>
50
+
</div>
51
+
52
+
<h2>3. Third-Party Data Processors</h2>
53
+
<p>We only share your data with the following third-party processors:</p>
54
+
55
+
<h3>Resend (Email Services)</h3>
56
+
<ul>
57
+
<li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
58
+
<li><strong>Data Shared:</strong> Email address and necessary message content</li>
59
+
<li><strong>Location:</strong> EU-compliant email delivery service</li>
60
+
</ul>
61
+
62
+
<h3>Cloudflare (Image Caching)</h3>
63
+
<ul>
64
+
<li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
65
+
<li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
66
+
<li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
67
+
</ul>
68
+
69
+
<h2>4. How We Use Your Information</h2>
70
+
<p>We use your information to:</p>
71
+
<ul>
72
+
<li>Provide and maintain the Service</li>
73
+
<li>Process your transactions and requests</li>
74
+
<li>Send you technical notices and support messages</li>
75
+
<li>Improve and develop new features</li>
76
+
<li>Ensure security and prevent fraud</li>
77
+
<li>Comply with legal obligations</li>
78
+
</ul>
79
+
80
+
<h2>5. Data Sharing and Disclosure</h2>
81
+
<p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
82
+
<ul>
83
+
<li>With the third-party processors listed above</li>
84
+
<li>When required by law or legal process</li>
85
+
<li>To protect our rights, property, or safety, or that of our users</li>
86
+
<li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
87
+
</ul>
88
+
89
+
<h2>6. Data Security</h2>
90
+
<p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
91
+
92
+
<h2>7. Data Retention</h2>
93
+
<p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
94
+
95
+
<h2>8. Your Rights</h2>
96
+
<p>Under applicable data protection laws, you have the right to:</p>
97
+
<ul>
98
+
<li>Access your personal information</li>
99
+
<li>Correct inaccurate information</li>
100
+
<li>Request deletion of your information</li>
101
+
<li>Object to processing of your information</li>
102
+
<li>Data portability</li>
103
+
<li>Withdraw consent (where applicable)</li>
104
+
</ul>
105
+
106
+
<h2>9. Cookies and Tracking</h2>
107
+
<p>We use cookies and similar technologies to:</p>
108
+
<ul>
109
+
<li>Maintain your login session</li>
110
+
<li>Remember your preferences</li>
111
+
<li>Analyze usage patterns to improve the Service</li>
112
+
</ul>
113
+
<p>You can control cookie settings through your browser preferences.</p>
114
+
115
+
<h2>10. Children's Privacy</h2>
116
+
<p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
117
+
118
+
<h2>11. International Data Transfers</h2>
119
+
<p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
120
+
121
+
<h2>12. Changes to This Privacy Policy</h2>
122
+
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
123
+
124
+
<h2>13. Contact Information</h2>
125
+
<p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
126
+
127
+
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128
+
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129
+
</div>
130
+
</div>
131
+
</div>
132
+
</div>
133
+
{{ end }}
+71
appview/pages/templates/legal/terms.html
+71
appview/pages/templates/legal/terms.html
···
1
+
{{ define "title" }}terms of service{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="max-w-4xl mx-auto px-4 py-8">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
+
<div class="prose prose-gray dark:prose-invert max-w-none">
7
+
<h1>Terms of Service</h1>
8
+
9
+
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
10
+
11
+
<p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
12
+
13
+
<h2>1. Acceptance of Terms</h2>
14
+
<p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
15
+
16
+
<h2>2. Account Registration</h2>
17
+
<p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
18
+
19
+
<h2>3. Account Termination</h2>
20
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
21
+
<h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
22
+
<p class="text-red-700 dark:text-red-300">
23
+
<strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
24
+
</p>
25
+
<p class="text-red-700 dark:text-red-300 mt-2">
26
+
Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
27
+
</p>
28
+
</div>
29
+
30
+
<h2>4. Acceptable Use</h2>
31
+
<p>You agree not to use the Service to:</p>
32
+
<ul>
33
+
<li>Violate any applicable laws or regulations</li>
34
+
<li>Infringe upon the rights of others</li>
35
+
<li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
36
+
<li>Engage in spam, phishing, or other deceptive practices</li>
37
+
<li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
38
+
<li>Interfere with or disrupt the Service or servers connected to the Service</li>
39
+
</ul>
40
+
41
+
<h2>5. Content and Intellectual Property</h2>
42
+
<p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
43
+
44
+
<h2>6. Privacy</h2>
45
+
<p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
46
+
47
+
<h2>7. Disclaimers</h2>
48
+
<p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
49
+
50
+
<h2>8. Limitation of Liability</h2>
51
+
<p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
52
+
53
+
<h2>9. Indemnification</h2>
54
+
<p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
55
+
56
+
<h2>10. Governing Law</h2>
57
+
<p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
58
+
59
+
<h2>11. Changes to Terms</h2>
60
+
<p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
61
+
62
+
<h2>12. Contact Information</h2>
63
+
<p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
64
+
65
+
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
66
+
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
67
+
</div>
68
+
</div>
69
+
</div>
70
+
</div>
71
+
{{ end }}
+4
-2
appview/pages/templates/repo/empty.html
+4
-2
appview/pages/templates/repo/empty.html
···
32
32
<div class="py-6 w-fit flex flex-col gap-4">
33
33
<p>This is an empty repository. To get started:</p>
34
34
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
35
-
<p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p>
36
-
<p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p>
35
+
<p><span class="{{$bullet}}">1</span>Add an SSH public key to your account from the <a href="/settings" class="underline">settings</a> page.
36
+
If you don't have one, you can generate a new SSH key pair using the following command: <code>ssh-keygen -t ed25519 -C "you@example.com"</code>
37
+
</p>
38
+
<p><span class="{{$bullet}}">2</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
37
39
<p><span class="{{$bullet}}">3</span>Push!</p>
38
40
</div>
39
41
</div>
+9
-21
appview/pages/templates/repo/index.html
+9
-21
appview/pages/templates/repo/index.html
···
170
170
{{ define "commitLog" }}
171
171
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
172
172
<div class="flex justify-between items-center">
173
-
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
174
-
<div class="flex gap-2 items-center font-bold">
175
-
{{ i "logs" "w-4 h-4" }} commits
176
-
</div>
177
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
178
-
view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }}
179
-
</span>
173
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
174
+
{{ i "logs" "w-4 h-4" }} commits
175
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span>
180
176
</a>
181
177
</div>
182
178
<div class="flex flex-col gap-6">
···
278
274
{{ define "branchList" }}
279
275
{{ if gt (len .BranchesTrunc) 0 }}
280
276
<div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
281
-
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
282
-
<div class="flex gap-2 items-center font-bold">
283
-
{{ i "git-branch" "w-4 h-4" }} branches
284
-
</div>
285
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
286
-
view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }}
287
-
</span>
277
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
278
+
{{ i "git-branch" "w-4 h-4" }} branches
279
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span>
288
280
</a>
289
281
<div class="flex flex-col gap-1">
290
282
{{ range .BranchesTrunc }}
···
321
313
{{ if gt (len .TagsTrunc) 0 }}
322
314
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
323
315
<div class="flex justify-between items-center">
324
-
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
325
-
<div class="flex gap-2 items-center font-bold">
326
-
{{ i "tags" "w-4 h-4" }} tags
327
-
</div>
328
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
329
-
view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }}
330
-
</span>
316
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
317
+
{{ i "tags" "w-4 h-4" }} tags
318
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span>
331
319
</a>
332
320
</div>
333
321
<div class="flex flex-col gap-1">
+2
-2
appview/pages/templates/repo/log.html
+2
-2
appview/pages/templates/repo/log.html
···
21
21
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
22
22
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
23
23
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div>
24
-
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Date</div>
24
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
25
25
</div>
26
26
{{ range $index, $commit := .Commits }}
27
27
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
···
85
85
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
86
86
{{ end }}
87
87
</div>
88
-
<div class="align-top text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
88
+
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
89
89
</div>
90
90
{{ end }}
91
91
</div>
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
···
19
19
20
20
{{ define "sidebar" }}
21
21
{{ $active := .Workflow }}
22
+
23
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
24
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
25
+
22
26
{{ with .Pipeline }}
23
27
{{ $id := .Id }}
24
28
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
25
29
{{ range $name, $all := .Statuses }}
26
30
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
27
31
<div
28
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
32
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
29
33
{{ $lastStatus := $all.Latest }}
30
34
{{ $kind := $lastStatus.Status.String }}
31
35
+33
-23
appview/pages/templates/repo/settings/pipelines.html
+33
-23
appview/pages/templates/repo/settings/pipelines.html
···
20
20
<div class="col-span-1 md:col-span-2">
21
21
<h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2>
22
22
<p class="text-gray-500 dark:text-gray-400">
23
-
Choose a spindle to execute your workflows on. Spindles can be
24
-
selfhosted,
23
+
Choose a spindle to execute your workflows on. Only repository owners
24
+
can configure spindles. Spindles can be selfhosted,
25
25
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
26
26
click to learn more.
27
27
</a>
28
28
</p>
29
29
</div>
30
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
31
-
<select
32
-
id="spindle"
33
-
name="spindle"
34
-
required
35
-
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
36
-
{{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
37
-
<option value="" disabled selected >
38
-
Choose a spindle
39
-
</option>
40
-
{{ range $.Spindles }}
41
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
42
-
{{ . }}
30
+
{{ if not $.RepoInfo.Roles.IsOwner }}
31
+
<div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
32
+
{{ or $.CurrentSpindle "No spindle configured" }}
33
+
</div>
34
+
{{ else }}
35
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
36
+
<select
37
+
id="spindle"
38
+
name="spindle"
39
+
required
40
+
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
+
{{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}}
42
+
<option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}>
43
+
{{ if not $.CurrentSpindle }}
44
+
Choose a spindle
45
+
{{ else }}
46
+
Disable pipelines
47
+
{{ end }}
43
48
</option>
44
-
{{ end }}
45
-
</select>
46
-
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
47
-
{{ i "check" "size-4" }}
48
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
-
</button>
50
-
</form>
49
+
{{ range $.Spindles }}
50
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
51
+
{{ . }}
52
+
</option>
53
+
{{ end }}
54
+
</select>
55
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
56
+
{{ i "check" "size-4" }}
57
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
+
</button>
59
+
</form>
60
+
{{ end }}
51
61
</div>
52
62
{{ end }}
53
63
···
77
87
{{ end }}
78
88
79
89
{{ define "addSecretButton" }}
80
-
<button
90
+
<button
81
91
class="btn flex items-center gap-2"
82
92
popovertarget="add-secret-modal"
83
93
popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
-168
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
3
-
{{ define "repoContent" }}
4
-
{{ template "collaboratorSettings" . }}
5
-
{{ template "branchSettings" . }}
6
-
{{ template "dangerZone" . }}
7
-
{{ template "spindleSelector" . }}
8
-
{{ template "spindleSecrets" . }}
9
-
{{ end }}
10
-
11
-
{{ define "collaboratorSettings" }}
12
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
-
Collaborators
14
-
</header>
15
-
16
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
-
{{ range .Collaborators }}
18
-
<div id="collaborator" class="mb-2">
19
-
<a
20
-
href="/{{ didOrHandle .Did .Handle }}"
21
-
class="no-underline hover:underline text-black dark:text-white"
22
-
>
23
-
{{ didOrHandle .Did .Handle }}
24
-
</a>
25
-
<div>
26
-
<span class="text-sm text-gray-500 dark:text-gray-400">
27
-
{{ .Role }}
28
-
</span>
29
-
</div>
30
-
</div>
31
-
{{ end }}
32
-
</div>
33
-
34
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
-
<form
36
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
-
class="group"
38
-
>
39
-
<label for="collaborator" class="dark:text-white">
40
-
add collaborator
41
-
</label>
42
-
<input
43
-
type="text"
44
-
id="collaborator"
45
-
name="collaborator"
46
-
required
47
-
class="dark:bg-gray-700 dark:text-white"
48
-
placeholder="enter did or handle">
49
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
-
<span>add</span>
51
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
-
</button>
53
-
</form>
54
-
{{ end }}
55
-
{{ end }}
56
-
57
-
{{ define "dangerZone" }}
58
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
-
<form
60
-
hx-confirm="Are you sure you want to delete this repository?"
61
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
-
class="mt-6"
63
-
hx-indicator="#delete-repo-spinner">
64
-
<label for="branch">delete repository</label>
65
-
<button class="btn my-2 flex items-center" type="text">
66
-
<span>delete</span>
67
-
<span id="delete-repo-spinner" class="group">
68
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
-
</span>
70
-
</button>
71
-
<span>
72
-
Deleting a repository is irreversible and permanent.
73
-
</span>
74
-
</form>
75
-
{{ end }}
76
-
{{ end }}
77
-
78
-
{{ define "branchSettings" }}
79
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
-
<label for="branch">default branch</label>
81
-
<div class="flex gap-2 items-center">
82
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
-
<option value="" disabled selected >
84
-
Choose a default branch
85
-
</option>
86
-
{{ range .Branches }}
87
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
-
{{ .Name }}
89
-
</option>
90
-
{{ end }}
91
-
</select>
92
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
-
<span>save</span>
94
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
-
</button>
96
-
</div>
97
-
</form>
98
-
{{ end }}
99
-
100
-
{{ define "spindleSelector" }}
101
-
{{ if .RepoInfo.Roles.IsOwner }}
102
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
-
<label for="spindle">spindle</label>
104
-
<div class="flex gap-2 items-center">
105
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
-
<option value="" selected >
107
-
None
108
-
</option>
109
-
{{ range .Spindles }}
110
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
-
{{ . }}
112
-
</option>
113
-
{{ end }}
114
-
</select>
115
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
-
<span>save</span>
117
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
-
</button>
119
-
</div>
120
-
</form>
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "spindleSecrets" }}
125
-
{{ if $.CurrentSpindle }}
126
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
-
Secrets
128
-
</header>
129
-
130
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
-
{{ range $idx, $secret := .Secrets }}
132
-
{{ with $secret }}
133
-
<div id="secret-{{$idx}}" class="mb-2">
134
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
-
</div>
136
-
{{ end }}
137
-
{{ end }}
138
-
</div>
139
-
<form
140
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
-
class="mt-6"
142
-
hx-indicator="#add-secret-spinner">
143
-
<label for="key">secret key</label>
144
-
<input
145
-
type="text"
146
-
id="key"
147
-
name="key"
148
-
required
149
-
class="dark:bg-gray-700 dark:text-white"
150
-
placeholder="SECRET_KEY" />
151
-
<label for="value">secret value</label>
152
-
<input
153
-
type="text"
154
-
id="value"
155
-
name="value"
156
-
required
157
-
class="dark:bg-gray-700 dark:text-white"
158
-
placeholder="SECRET VALUE" />
159
-
160
-
<button class="btn my-2 flex items-center" type="text">
161
-
<span>add</span>
162
-
<span id="add-secret-spinner" class="group">
163
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
-
</span>
165
-
</button>
166
-
</form>
167
-
{{ end }}
168
-
{{ end }}
+3
-2
appview/pages/templates/repo/tree.html
+3
-2
appview/pages/templates/repo/tree.html
···
61
61
62
62
{{ if .IsFile }}
63
63
{{ $icon = "file" }}
64
-
{{ $iconStyle = "size-4" }}
64
+
{{ $iconStyle = "flex-shrink-0 size-4" }}
65
65
{{ end }}
66
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
67
<div class="flex items-center gap-2">
68
-
{{ i $icon $iconStyle }}{{ .Name }}
68
+
{{ i $icon $iconStyle }}
69
+
<span class="truncate">{{ .Name }}</span>
69
70
</div>
70
71
</a>
71
72
</div>
+57
appview/pages/templates/strings/dashboard.html
+57
appview/pages/templates/strings/dashboard.html
···
1
+
{{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
11
+
{{ define "content" }}
12
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
13
+
<div class="md:col-span-3 order-1 md:order-1">
14
+
{{ template "user/fragments/profileCard" .Card }}
15
+
</div>
16
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
17
+
{{ block "allStrings" . }}{{ end }}
18
+
</div>
19
+
</div>
20
+
{{ end }}
21
+
22
+
{{ define "allStrings" }}
23
+
<p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p>
24
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
25
+
{{ range .Strings }}
26
+
{{ template "singleString" (list $ .) }}
27
+
{{ else }}
28
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
29
+
{{ end }}
30
+
</div>
31
+
{{ end }}
32
+
33
+
{{ define "singleString" }}
34
+
{{ $root := index . 0 }}
35
+
{{ $s := index . 1 }}
36
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
37
+
<div class="font-medium dark:text-white flex gap-2 items-center">
38
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
39
+
</div>
40
+
{{ with $s.Description }}
41
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
42
+
{{ . }}
43
+
</div>
44
+
{{ end }}
45
+
46
+
{{ $stat := $s.Stats }}
47
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
48
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
49
+
<span class="select-none [&:before]:content-['ยท']"></span>
50
+
{{ with $s.Edited }}
51
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
52
+
{{ else }}
53
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
54
+
{{ end }}
55
+
</div>
56
+
</div>
57
+
{{ end }}
+89
appview/pages/templates/strings/fragments/form.html
+89
appview/pages/templates/strings/fragments/form.html
···
1
+
{{ define "strings/fragments/form" }}
2
+
<form
3
+
{{ if eq .Action "new" }}
4
+
hx-post="/strings/new"
5
+
{{ else }}
6
+
hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit"
7
+
{{ end }}
8
+
hx-indicator="#new-button"
9
+
class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded"
10
+
hx-swap="none">
11
+
<div class="flex flex-col md:flex-row md:items-center gap-2">
12
+
<input
13
+
type="text"
14
+
id="filename"
15
+
name="filename"
16
+
placeholder="Filename with extension"
17
+
required
18
+
value="{{ .String.Filename }}"
19
+
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
20
+
>
21
+
<input
22
+
type="text"
23
+
id="description"
24
+
name="description"
25
+
value="{{ .String.Description }}"
26
+
placeholder="Description ..."
27
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
28
+
>
29
+
</div>
30
+
<textarea
31
+
name="content"
32
+
id="content-textarea"
33
+
wrap="off"
34
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
35
+
rows="20"
36
+
placeholder="Paste your string here!"
37
+
required>{{ .String.Contents }}</textarea>
38
+
<div class="flex justify-between items-center">
39
+
<div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400">
40
+
<span id="line-count">0 lines</span>
41
+
<span class="select-none px-1 [&:before]:content-['ยท']"></span>
42
+
<span id="byte-count">0 bytes</span>
43
+
</div>
44
+
<div id="actions" class="flex gap-2 items-center">
45
+
{{ if eq .Action "edit" }}
46
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 "
47
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}">
48
+
{{ i "x" "size-4" }}
49
+
<span class="hidden md:inline">cancel</span>
50
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
51
+
</a>
52
+
{{ end }}
53
+
<button
54
+
type="submit"
55
+
id="new-button"
56
+
class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
57
+
>
58
+
<span class="inline-flex items-center gap-2">
59
+
{{ i "arrow-up" "w-4 h-4" }}
60
+
publish
61
+
</span>
62
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
63
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
64
+
</span>
65
+
</button>
66
+
</div>
67
+
</div>
68
+
<script>
69
+
(function() {
70
+
const textarea = document.getElementById('content-textarea');
71
+
const lineCount = document.getElementById('line-count');
72
+
const byteCount = document.getElementById('byte-count');
73
+
function updateStats() {
74
+
const content = textarea.value;
75
+
const lines = content === '' ? 0 : content.split('\n').length;
76
+
const bytes = new TextEncoder().encode(content).length;
77
+
lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`;
78
+
byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`;
79
+
}
80
+
textarea.addEventListener('input', updateStats);
81
+
textarea.addEventListener('paste', () => {
82
+
setTimeout(updateStats, 0);
83
+
});
84
+
updateStats();
85
+
})();
86
+
</script>
87
+
<div id="error" class="error dark:text-red-400"></div>
88
+
</form>
89
+
{{ end }}
+17
appview/pages/templates/strings/put.html
+17
appview/pages/templates/strings/put.html
···
1
+
{{ define "title" }}publish a new string{{ end }}
2
+
3
+
{{ define "topbar" }}
4
+
{{ template "layouts/topbar" $ }}
5
+
{{ end }}
6
+
7
+
{{ define "content" }}
8
+
<div class="px-6 py-2 mb-4">
9
+
{{ if eq .Action "new" }}
10
+
<p class="text-xl font-bold dark:text-white">Create a new string</p>
11
+
<p class="">Store and share code snippets with ease.</p>
12
+
{{ else }}
13
+
<p class="text-xl font-bold dark:text-white">Edit string</p>
14
+
{{ end }}
15
+
</div>
16
+
{{ template "strings/fragments/form" . }}
17
+
{{ end }}
+85
appview/pages/templates/strings/string.html
+85
appview/pages/templates/strings/string.html
···
1
+
{{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
+
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
6
+
<meta property="og:type" content="object" />
7
+
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
8
+
<meta property="og:description" content="{{ .String.Description }}" />
9
+
{{ end }}
10
+
11
+
{{ define "topbar" }}
12
+
{{ template "layouts/topbar" $ }}
13
+
{{ end }}
14
+
15
+
{{ define "content" }}
16
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
17
+
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
18
+
<div class="text-lg flex items-center justify-between">
19
+
<div>
20
+
<a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a>
21
+
<span class="select-none">/</span>
22
+
<a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
23
+
</div>
24
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
25
+
<div class="flex gap-2 text-base">
26
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
27
+
hx-boost="true"
28
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
29
+
{{ i "pencil" "size-4" }}
30
+
<span class="hidden md:inline">edit</span>
31
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
32
+
</a>
33
+
<button
34
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2"
35
+
title="Delete string"
36
+
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
37
+
hx-swap="none"
38
+
hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?"
39
+
>
40
+
{{ i "trash-2" "size-4" }}
41
+
<span class="hidden md:inline">delete</span>
42
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
43
+
</button>
44
+
</div>
45
+
{{ end }}
46
+
</div>
47
+
<span class="flex items-center">
48
+
{{ with .String.Description }}
49
+
{{ . }}
50
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
51
+
{{ end }}
52
+
53
+
{{ with .String.Edited }}
54
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
55
+
{{ else }}
56
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
57
+
{{ end }}
58
+
</span>
59
+
</section>
60
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
61
+
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
62
+
<span>{{ .String.Filename }}</span>
63
+
<div>
64
+
<span>{{ .Stats.LineCount }} lines</span>
65
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
66
+
<span>{{ byteFmt .Stats.ByteCount }}</span>
67
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
68
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a>
69
+
{{ if .RenderToggle }}
70
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
71
+
<a href="?code={{ .ShowRendered }}" hx-boost="true">
72
+
view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}
73
+
</a>
74
+
{{ end }}
75
+
</div>
76
+
</div>
77
+
<div class="overflow-auto relative">
78
+
{{ if .ShowRendered }}
79
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
80
+
{{ else }}
81
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
82
+
{{ end }}
83
+
</div>
84
+
</section>
85
+
{{ end }}
+2
-2
appview/pages/templates/timeline.html
+2
-2
appview/pages/templates/timeline.html
···
34
34
</p>
35
35
36
36
<div class="flex gap-6 items-center">
37
-
<a href="/login" class="no-underline hover:no-underline ">
38
-
<button class="btn flex gap-2 px-4 items-center">
37
+
<a href="/signup" class="no-underline hover:no-underline ">
38
+
<button class="btn-create flex gap-2 px-4 items-center">
39
39
join now {{ i "arrow-right" "size-4" }}
40
40
</button>
41
41
</a>
+104
appview/pages/templates/user/completeSignup.html
+104
appview/pages/templates/user/completeSignup.html
···
1
+
{{ define "user/completeSignup" }}
2
+
<!doctype html>
3
+
<html lang="en" class="dark:bg-gray-900">
4
+
<head>
5
+
<meta charset="UTF-8" />
6
+
<meta
7
+
name="viewport"
8
+
content="width=device-width, initial-scale=1.0"
9
+
/>
10
+
<meta
11
+
property="og:title"
12
+
content="complete signup ยท tangled"
13
+
/>
14
+
<meta
15
+
property="og:url"
16
+
content="https://tangled.sh/complete-signup"
17
+
/>
18
+
<meta
19
+
property="og:description"
20
+
content="complete your signup for tangled"
21
+
/>
22
+
<script src="/static/htmx.min.js"></script>
23
+
<link
24
+
rel="stylesheet"
25
+
href="/static/tw.css?{{ cssContentHash }}"
26
+
type="text/css"
27
+
/>
28
+
<title>complete signup · tangled</title>
29
+
</head>
30
+
<body class="flex items-center justify-center min-h-screen">
31
+
<main class="max-w-md px-6 -mt-4">
32
+
<h1
33
+
class="text-center text-2xl font-semibold italic dark:text-white"
34
+
>
35
+
tangled
36
+
</h1>
37
+
<h2 class="text-center text-xl italic dark:text-white">
38
+
tightly-knit social coding.
39
+
</h2>
40
+
<form
41
+
class="mt-4 max-w-sm mx-auto flex flex-col gap-4"
42
+
hx-post="/signup/complete"
43
+
hx-swap="none"
44
+
hx-disabled-elt="#complete-signup-button"
45
+
>
46
+
<div class="flex flex-col">
47
+
<label for="code">verification code</label>
48
+
<input
49
+
type="text"
50
+
id="code"
51
+
name="code"
52
+
tabindex="1"
53
+
required
54
+
placeholder="tngl-sh-foo-bar"
55
+
/>
56
+
<span class="text-sm text-gray-500 mt-1">
57
+
Enter the code sent to your email.
58
+
</span>
59
+
</div>
60
+
61
+
<div class="flex flex-col">
62
+
<label for="username">username</label>
63
+
<input
64
+
type="text"
65
+
id="username"
66
+
name="username"
67
+
tabindex="2"
68
+
required
69
+
placeholder="jason"
70
+
/>
71
+
<span class="text-sm text-gray-500 mt-1">
72
+
Your complete handle will be of the form <code>user.tngl.sh</code>.
73
+
</span>
74
+
</div>
75
+
76
+
<div class="flex flex-col">
77
+
<label for="password">password</label>
78
+
<input
79
+
type="password"
80
+
id="password"
81
+
name="password"
82
+
tabindex="3"
83
+
required
84
+
/>
85
+
<span class="text-sm text-gray-500 mt-1">
86
+
Choose a strong password for your account.
87
+
</span>
88
+
</div>
89
+
90
+
<button
91
+
class="btn-create w-full my-2 mt-6 text-base"
92
+
type="submit"
93
+
id="complete-signup-button"
94
+
tabindex="4"
95
+
>
96
+
<span>complete signup</span>
97
+
</button>
98
+
</form>
99
+
<p id="signup-error" class="error w-full"></p>
100
+
<p id="signup-msg" class="dark:text-white w-full"></p>
101
+
</main>
102
+
</body>
103
+
</html>
104
+
{{ end }}
+1
-3
appview/pages/templates/user/fragments/profileCard.html
+1
-3
appview/pages/templates/user/fragments/profileCard.html
···
2
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
-
{{ if .AvatarUri }}
6
5
<div class="w-3/4 aspect-square relative">
7
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
6
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
8
7
</div>
9
-
{{ end }}
10
8
</div>
11
9
<div class="col-span-2">
12
10
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+5
-4
appview/pages/templates/user/fragments/repoCard.html
+5
-4
appview/pages/templates/user/fragments/repoCard.html
···
28
28
{{ define "repoStats" }}
29
29
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
30
30
{{ with .Language }}
31
-
<div class="flex gap-2 items-center text-sm">
32
-
<div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div>
33
-
<span>{{ . }}</span>
34
-
</div>
31
+
<div class="flex gap-2 items-center text-sm">
32
+
<div class="size-2 rounded-full"
33
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div>
34
+
<span>{{ . }}</span>
35
+
</div>
35
36
{{ end }}
36
37
{{ with .StarCount }}
37
38
<div class="flex gap-1 items-center text-sm">
+13
-34
appview/pages/templates/user/login.html
+13
-34
appview/pages/templates/user/login.html
···
3
3
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
5
<meta charset="UTF-8" />
6
-
<meta
7
-
name="viewport"
8
-
content="width=device-width, initial-scale=1.0"
9
-
/>
10
-
<meta
11
-
property="og:title"
12
-
content="login ยท tangled"
13
-
/>
14
-
<meta
15
-
property="og:url"
16
-
content="https://tangled.sh/login"
17
-
/>
18
-
<meta
19
-
property="og:description"
20
-
content="login to tangled"
21
-
/>
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="login ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/login" />
9
+
<meta property="og:description" content="login to for tangled" />
22
10
<script src="/static/htmx.min.js"></script>
23
-
<link
24
-
rel="stylesheet"
25
-
href="/static/tw.css?{{ cssContentHash }}"
26
-
type="text/css"
27
-
/>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
28
12
<title>login · tangled</title>
29
13
</head>
30
14
<body class="flex items-center justify-center min-h-screen">
31
15
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
35
17
tangled
36
18
</h1>
37
19
<h2 class="text-center text-xl italic dark:text-white">
···
51
33
name="handle"
52
34
tabindex="1"
53
35
required
36
+
placeholder="akshay.tngl.sh"
54
37
/>
55
38
<span class="text-sm text-gray-500 mt-1">
56
-
Use your
57
-
<a href="https://bsky.app">Bluesky</a> handle to log
58
-
in. You will then be redirected to your PDS to
59
-
complete authentication.
39
+
Use your <a href="https://atproto.com">ATProto</a>
40
+
handle to log in. If you're unsure, this is likely
41
+
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
60
42
</span>
61
43
</div>
62
44
63
45
<button
64
-
class="btn w-full my-2 mt-6"
46
+
class="btn w-full my-2 mt-6 text-base "
65
47
type="submit"
66
48
id="login-button"
67
49
tabindex="3"
···
70
52
</button>
71
53
</form>
72
54
<p class="text-sm text-gray-500">
73
-
Join our <a href="https://chat.tangled.sh">Discord</a> or
74
-
IRC channel:
75
-
<a href="https://web.libera.chat/#tangled"
76
-
><code>#tangled</code> on Libera Chat</a
77
-
>.
55
+
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
78
56
</p>
57
+
79
58
<p id="login-msg" class="error w-full"></p>
80
59
</main>
81
60
</body>
+53
appview/pages/templates/user/signup.html
+53
appview/pages/templates/user/signup.html
···
1
+
{{ define "user/signup" }}
2
+
<!doctype html>
3
+
<html lang="en" class="dark:bg-gray-900">
4
+
<head>
5
+
<meta charset="UTF-8" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="signup ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/signup" />
9
+
<meta property="og:description" content="sign up for tangled" />
10
+
<script src="/static/htmx.min.js"></script>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
+
<title>sign up · tangled</title>
13
+
</head>
14
+
<body class="flex items-center justify-center min-h-screen">
15
+
<main class="max-w-md px-6 -mt-4">
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
17
+
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
18
+
<form
19
+
class="mt-4 max-w-sm mx-auto"
20
+
hx-post="/signup"
21
+
hx-swap="none"
22
+
hx-disabled-elt="#signup-button"
23
+
>
24
+
<div class="flex flex-col mt-2">
25
+
<label for="email">email</label>
26
+
<input
27
+
type="email"
28
+
id="email"
29
+
name="email"
30
+
tabindex="4"
31
+
required
32
+
placeholder="jason@bourne.co"
33
+
/>
34
+
</div>
35
+
<span class="text-sm text-gray-500 mt-1">
36
+
You will receive an email with an invite code. Enter your
37
+
invite code, desired username, and password in the next
38
+
page to complete your registration.
39
+
</span>
40
+
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
41
+
<span>join now</span>
42
+
</button>
43
+
</form>
44
+
<p class="text-sm text-gray-500">
45
+
Already have an account? <a href="/login" class="underline">Login to Tangled</a>.
46
+
</p>
47
+
48
+
<p id="signup-msg" class="error w-full"></p>
49
+
</main>
50
+
</body>
51
+
</html>
52
+
{{ end }}
53
+
+69
-20
appview/repo/repo.go
+69
-20
appview/repo/repo.go
···
39
39
"github.com/go-git/go-git/v5/plumbing"
40
40
41
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
+
"github.com/bluesky-social/indigo/atproto/syntax"
42
43
lexutil "github.com/bluesky-social/indigo/lex/util"
43
44
)
44
45
···
656
657
}
657
658
658
659
newSpindle := r.FormValue("spindle")
660
+
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
659
661
client, err := rp.oauth.AuthorizedClient(r)
660
662
if err != nil {
661
663
fail("Failed to authorize. Try again later.", err)
662
664
return
663
665
}
664
666
665
-
// ensure that this is a valid spindle for this user
666
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
667
-
if err != nil {
668
-
fail("Failed to find spindles. Try again later.", err)
669
-
return
667
+
if !removingSpindle {
668
+
// ensure that this is a valid spindle for this user
669
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
670
+
if err != nil {
671
+
fail("Failed to find spindles. Try again later.", err)
672
+
return
673
+
}
674
+
675
+
if !slices.Contains(validSpindles, newSpindle) {
676
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
677
+
return
678
+
}
670
679
}
671
680
672
-
if !slices.Contains(validSpindles, newSpindle) {
673
-
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
674
-
return
681
+
spindlePtr := &newSpindle
682
+
if removingSpindle {
683
+
spindlePtr = nil
675
684
}
676
685
677
686
// optimistic update
678
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
687
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
679
688
if err != nil {
680
689
fail("Failed to update spindle. Try again later.", err)
681
690
return
···
698
707
Owner: user.Did,
699
708
CreatedAt: f.CreatedAt,
700
709
Description: &f.Description,
701
-
Spindle: &newSpindle,
710
+
Spindle: spindlePtr,
702
711
},
703
712
},
704
713
})
···
708
717
return
709
718
}
710
719
711
-
// add this spindle to spindle stream
712
-
rp.spindlestream.AddSource(
713
-
context.Background(),
714
-
eventconsumer.NewSpindleSource(newSpindle),
715
-
)
720
+
if !removingSpindle {
721
+
// add this spindle to spindle stream
722
+
rp.spindlestream.AddSource(
723
+
context.Background(),
724
+
eventconsumer.NewSpindleSource(newSpindle),
725
+
)
726
+
}
716
727
717
728
rp.pages.HxRefresh(w)
718
729
}
···
741
752
return
742
753
}
743
754
755
+
// remove a single leading `@`, to make @handle work with ResolveIdent
756
+
collaborator = strings.TrimPrefix(collaborator, "@")
757
+
744
758
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
745
759
if err != nil {
746
760
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
···
751
765
fail("You seem to be adding yourself as a collaborator.", nil)
752
766
return
753
767
}
754
-
755
768
l = l.With("collaborator", collaboratorIdent.Handle)
756
769
l = l.With("knot", f.Knot)
757
-
l.Info("adding to knot")
770
+
771
+
// announce this relation into the firehose, store into owners' pds
772
+
client, err := rp.oauth.AuthorizedClient(r)
773
+
if err != nil {
774
+
fail("Failed to write to PDS.", err)
775
+
return
776
+
}
758
777
778
+
// emit a record
779
+
currentUser := rp.oauth.GetUser(r)
780
+
rkey := tid.TID()
781
+
createdAt := time.Now()
782
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
783
+
Collection: tangled.RepoCollaboratorNSID,
784
+
Repo: currentUser.Did,
785
+
Rkey: rkey,
786
+
Record: &lexutil.LexiconTypeDecoder{
787
+
Val: &tangled.RepoCollaborator{
788
+
Subject: collaboratorIdent.DID.String(),
789
+
Repo: string(f.RepoAt),
790
+
CreatedAt: createdAt.Format(time.RFC3339),
791
+
}},
792
+
})
793
+
// invalid record
794
+
if err != nil {
795
+
fail("Failed to write record to PDS.", err)
796
+
return
797
+
}
798
+
l = l.With("at-uri", resp.Uri)
799
+
l.Info("wrote record to PDS")
800
+
801
+
l.Info("adding to knot")
759
802
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
760
803
if err != nil {
761
804
fail("Failed to add to knot.", err)
···
798
841
return
799
842
}
800
843
801
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
844
+
err = db.AddCollaborator(rp.db, db.Collaborator{
845
+
Did: syntax.DID(currentUser.Did),
846
+
Rkey: rkey,
847
+
SubjectDid: collaboratorIdent.DID,
848
+
RepoAt: f.RepoAt,
849
+
Created: createdAt,
850
+
})
802
851
if err != nil {
803
852
fail("Failed to add collaborator.", err)
804
853
return
···
1189
1238
f, err := rp.repoResolver.Resolve(r)
1190
1239
user := rp.oauth.GetUser(r)
1191
1240
1192
-
// all spindles that this user is a member of
1193
-
spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1241
+
// all spindles that the repo owner is a member of
1242
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1194
1243
if err != nil {
1195
1244
log.Println("failed to fetch spindles", err)
1196
1245
return
+104
appview/signup/requests.go
+104
appview/signup/requests.go
···
1
+
package signup
2
+
3
+
// We have this extra code here for now since the xrpcclient package
4
+
// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.
5
+
6
+
import (
7
+
"bytes"
8
+
"encoding/json"
9
+
"fmt"
10
+
"io"
11
+
"net/http"
12
+
"net/url"
13
+
)
14
+
15
+
// makePdsRequest is a helper method to make requests to the PDS service
16
+
func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {
17
+
jsonData, err := json.Marshal(body)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
22
+
url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)
23
+
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
24
+
if err != nil {
25
+
return nil, err
26
+
}
27
+
28
+
req.Header.Set("Content-Type", "application/json")
29
+
30
+
if useAuth {
31
+
req.SetBasicAuth("admin", s.config.Pds.AdminSecret)
32
+
}
33
+
34
+
return http.DefaultClient.Do(req)
35
+
}
36
+
37
+
// handlePdsError processes error responses from the PDS service
38
+
func (s *Signup) handlePdsError(resp *http.Response, action string) error {
39
+
var errorResp struct {
40
+
Error string `json:"error"`
41
+
Message string `json:"message"`
42
+
}
43
+
44
+
respBody, _ := io.ReadAll(resp.Body)
45
+
if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {
46
+
return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)
47
+
}
48
+
49
+
// Fallback if we couldn't parse the error
50
+
return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)
51
+
}
52
+
53
+
func (s *Signup) inviteCodeRequest() (string, error) {
54
+
body := map[string]any{"useCount": 1}
55
+
56
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)
57
+
if err != nil {
58
+
return "", err
59
+
}
60
+
defer resp.Body.Close()
61
+
62
+
if resp.StatusCode != http.StatusOK {
63
+
return "", s.handlePdsError(resp, "create invite code")
64
+
}
65
+
66
+
var result map[string]string
67
+
json.NewDecoder(resp.Body).Decode(&result)
68
+
return result["code"], nil
69
+
}
70
+
71
+
func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {
72
+
parsedURL, err := url.Parse(s.config.Pds.Host)
73
+
if err != nil {
74
+
return "", fmt.Errorf("invalid PDS host URL: %w", err)
75
+
}
76
+
77
+
pdsDomain := parsedURL.Hostname()
78
+
79
+
body := map[string]string{
80
+
"email": email,
81
+
"handle": fmt.Sprintf("%s.%s", username, pdsDomain),
82
+
"password": password,
83
+
"inviteCode": code,
84
+
}
85
+
86
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)
87
+
if err != nil {
88
+
return "", err
89
+
}
90
+
defer resp.Body.Close()
91
+
92
+
if resp.StatusCode != http.StatusOK {
93
+
return "", s.handlePdsError(resp, "create account")
94
+
}
95
+
96
+
var result struct {
97
+
DID string `json:"did"`
98
+
}
99
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
100
+
return "", fmt.Errorf("failed to decode create account response: %w", err)
101
+
}
102
+
103
+
return result.DID, nil
104
+
}
+256
appview/signup/signup.go
+256
appview/signup/signup.go
···
1
+
package signup
2
+
3
+
import (
4
+
"bufio"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
"os"
9
+
"strings"
10
+
11
+
"github.com/go-chi/chi/v5"
12
+
"github.com/posthog/posthog-go"
13
+
"tangled.sh/tangled.sh/core/appview/config"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/dns"
16
+
"tangled.sh/tangled.sh/core/appview/email"
17
+
"tangled.sh/tangled.sh/core/appview/pages"
18
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
+
)
22
+
23
+
type Signup struct {
24
+
config *config.Config
25
+
db *db.DB
26
+
cf *dns.Cloudflare
27
+
posthog posthog.Client
28
+
xrpc *xrpcclient.Client
29
+
idResolver *idresolver.Resolver
30
+
pages *pages.Pages
31
+
l *slog.Logger
32
+
disallowedNicknames map[string]bool
33
+
}
34
+
35
+
func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
36
+
var cf *dns.Cloudflare
37
+
if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" {
38
+
var err error
39
+
cf, err = dns.NewCloudflare(cfg)
40
+
if err != nil {
41
+
l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
42
+
}
43
+
}
44
+
45
+
disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
46
+
47
+
return &Signup{
48
+
config: cfg,
49
+
db: database,
50
+
posthog: pc,
51
+
idResolver: idResolver,
52
+
cf: cf,
53
+
pages: pages,
54
+
l: l,
55
+
disallowedNicknames: disallowedNicknames,
56
+
}
57
+
}
58
+
59
+
func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool {
60
+
disallowed := make(map[string]bool)
61
+
62
+
if filepath == "" {
63
+
logger.Debug("no disallowed nicknames file configured")
64
+
return disallowed
65
+
}
66
+
67
+
file, err := os.Open(filepath)
68
+
if err != nil {
69
+
logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err)
70
+
return disallowed
71
+
}
72
+
defer file.Close()
73
+
74
+
scanner := bufio.NewScanner(file)
75
+
lineNum := 0
76
+
for scanner.Scan() {
77
+
lineNum++
78
+
line := strings.TrimSpace(scanner.Text())
79
+
if line == "" || strings.HasPrefix(line, "#") {
80
+
continue // skip empty lines and comments
81
+
}
82
+
83
+
nickname := strings.ToLower(line)
84
+
if userutil.IsValidSubdomain(nickname) {
85
+
disallowed[nickname] = true
86
+
} else {
87
+
logger.Warn("invalid nickname format in disallowed nicknames file",
88
+
"file", filepath, "line", lineNum, "nickname", nickname)
89
+
}
90
+
}
91
+
92
+
if err := scanner.Err(); err != nil {
93
+
logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err)
94
+
}
95
+
96
+
logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath)
97
+
return disallowed
98
+
}
99
+
100
+
// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
101
+
func (s *Signup) isNicknameAllowed(nickname string) bool {
102
+
return !s.disallowedNicknames[strings.ToLower(nickname)]
103
+
}
104
+
105
+
func (s *Signup) Router() http.Handler {
106
+
r := chi.NewRouter()
107
+
r.Get("/", s.signup)
108
+
r.Post("/", s.signup)
109
+
r.Get("/complete", s.complete)
110
+
r.Post("/complete", s.complete)
111
+
112
+
return r
113
+
}
114
+
115
+
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
116
+
switch r.Method {
117
+
case http.MethodGet:
118
+
s.pages.Signup(w)
119
+
case http.MethodPost:
120
+
if s.cf == nil {
121
+
http.Error(w, "signup is disabled", http.StatusFailedDependency)
122
+
}
123
+
emailId := r.FormValue("email")
124
+
125
+
noticeId := "signup-msg"
126
+
if !email.IsValidEmail(emailId) {
127
+
s.pages.Notice(w, noticeId, "Invalid email address.")
128
+
return
129
+
}
130
+
131
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
132
+
if err != nil {
133
+
s.l.Error("failed to check email existence", "error", err)
134
+
s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
135
+
return
136
+
}
137
+
if exists {
138
+
s.pages.Notice(w, noticeId, "Email already exists.")
139
+
return
140
+
}
141
+
142
+
code, err := s.inviteCodeRequest()
143
+
if err != nil {
144
+
s.l.Error("failed to create invite code", "error", err)
145
+
s.pages.Notice(w, noticeId, "Failed to create invite code.")
146
+
return
147
+
}
148
+
149
+
em := email.Email{
150
+
APIKey: s.config.Resend.ApiKey,
151
+
From: s.config.Resend.SentFrom,
152
+
To: emailId,
153
+
Subject: "Verify your Tangled account",
154
+
Text: `Copy and paste this code below to verify your account on Tangled.
155
+
` + code,
156
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
157
+
<p><code>` + code + `</code></p>`,
158
+
}
159
+
160
+
err = email.SendEmail(em)
161
+
if err != nil {
162
+
s.l.Error("failed to send email", "error", err)
163
+
s.pages.Notice(w, noticeId, "Failed to send email.")
164
+
return
165
+
}
166
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
167
+
Email: emailId,
168
+
InviteCode: code,
169
+
})
170
+
if err != nil {
171
+
s.l.Error("failed to add inflight signup", "error", err)
172
+
s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
173
+
return
174
+
}
175
+
176
+
s.pages.HxRedirect(w, "/signup/complete")
177
+
}
178
+
}
179
+
180
+
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
181
+
switch r.Method {
182
+
case http.MethodGet:
183
+
s.pages.CompleteSignup(w)
184
+
case http.MethodPost:
185
+
username := r.FormValue("username")
186
+
password := r.FormValue("password")
187
+
code := r.FormValue("code")
188
+
189
+
if !userutil.IsValidSubdomain(username) {
190
+
s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.")
191
+
return
192
+
}
193
+
194
+
if !s.isNicknameAllowed(username) {
195
+
s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
196
+
return
197
+
}
198
+
199
+
email, err := db.GetEmailForCode(s.db, code)
200
+
if err != nil {
201
+
s.l.Error("failed to get email for code", "error", err)
202
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
203
+
return
204
+
}
205
+
206
+
did, err := s.createAccountRequest(username, password, email, code)
207
+
if err != nil {
208
+
s.l.Error("failed to create account", "error", err)
209
+
s.pages.Notice(w, "signup-error", err.Error())
210
+
return
211
+
}
212
+
213
+
if s.cf == nil {
214
+
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
215
+
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
216
+
return
217
+
}
218
+
219
+
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
220
+
Type: "TXT",
221
+
Name: "_atproto." + username,
222
+
Content: fmt.Sprintf(`"did=%s"`, did),
223
+
TTL: 6400,
224
+
Proxied: false,
225
+
})
226
+
if err != nil {
227
+
s.l.Error("failed to create DNS record", "error", err)
228
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
229
+
return
230
+
}
231
+
232
+
err = db.AddEmail(s.db, db.Email{
233
+
Did: did,
234
+
Address: email,
235
+
Verified: true,
236
+
Primary: true,
237
+
})
238
+
if err != nil {
239
+
s.l.Error("failed to add email", "error", err)
240
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
241
+
return
242
+
}
243
+
244
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
245
+
<a class="underline text-black dark:text-white" href="/login">login</a>
246
+
with <code>%s.tngl.sh</code>.`, username))
247
+
248
+
go func() {
249
+
err := db.DeleteInflightSignup(s.db, email)
250
+
if err != nil {
251
+
s.l.Error("failed to delete inflight signup", "error", err)
252
+
}
253
+
}()
254
+
return
255
+
}
256
+
}
+4
-4
appview/spindles/spindles.go
+4
-4
appview/spindles/spindles.go
···
619
619
620
620
if string(spindles[0].Owner) != user.Did {
621
621
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
622
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
622
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
623
623
return
624
624
}
625
625
626
626
member := r.FormValue("member")
627
627
if member == "" {
628
628
l.Error("empty member")
629
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
629
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
630
630
return
631
631
}
632
632
l = l.With("member", member)
···
634
634
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
635
635
if err != nil {
636
636
l.Error("failed to resolve member identity to handle", "err", err)
637
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
637
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
638
638
return
639
639
}
640
640
if memberId.Handle.IsInvalidHandle() {
641
641
l.Error("failed to resolve member identity to handle")
642
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
642
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
643
643
return
644
644
}
645
645
-16
appview/state/profile.go
-16
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
7
4
"fmt"
8
5
"log"
9
6
"net/http"
···
142
139
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
143
140
}
144
141
145
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
146
142
s.pages.ProfilePage(w, pages.ProfilePageParams{
147
143
LoggedInUser: loggedInUser,
148
144
Repos: pinnedRepos,
···
151
147
Card: pages.ProfileCard{
152
148
UserDid: ident.DID.String(),
153
149
UserHandle: ident.Handle.String(),
154
-
AvatarUri: profileAvatarUri,
155
150
Profile: profile,
156
151
FollowStatus: followStatus,
157
152
Followers: followers,
···
194
189
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
195
190
}
196
191
197
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
198
-
199
192
s.pages.ReposPage(w, pages.ReposPageParams{
200
193
LoggedInUser: loggedInUser,
201
194
Repos: repos,
···
203
196
Card: pages.ProfileCard{
204
197
UserDid: ident.DID.String(),
205
198
UserHandle: ident.Handle.String(),
206
-
AvatarUri: profileAvatarUri,
207
199
Profile: profile,
208
200
FollowStatus: followStatus,
209
201
Followers: followers,
210
202
Following: following,
211
203
},
212
204
})
213
-
}
214
-
215
-
func (s *State) GetAvatarUri(handle string) string {
216
-
secret := s.config.Avatar.SharedSecret
217
-
h := hmac.New(sha256.New, []byte(secret))
218
-
h.Write([]byte(handle))
219
-
signature := hex.EncodeToString(h.Sum(nil))
220
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
221
205
}
222
206
223
207
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+30
-3
appview/state/router.go
+30
-3
appview/state/router.go
···
14
14
"tangled.sh/tangled.sh/core/appview/pulls"
15
15
"tangled.sh/tangled.sh/core/appview/repo"
16
16
"tangled.sh/tangled.sh/core/appview/settings"
17
+
"tangled.sh/tangled.sh/core/appview/signup"
17
18
"tangled.sh/tangled.sh/core/appview/spindles"
18
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
19
21
"tangled.sh/tangled.sh/core/log"
20
22
)
21
23
···
65
67
66
68
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
67
69
r := chi.NewRouter()
68
-
69
-
// strip @ from user
70
-
r.Use(middleware.StripLeadingAt)
71
70
72
71
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
73
72
r.Get("/", s.Profile)
···
135
134
})
136
135
137
136
r.Mount("/settings", s.SettingsRouter())
137
+
r.Mount("/strings", s.StringsRouter(mw))
138
138
r.Mount("/knots", s.KnotsRouter(mw))
139
139
r.Mount("/spindles", s.SpindlesRouter())
140
+
r.Mount("/signup", s.SignupRouter())
140
141
r.Mount("/", s.OAuthRouter())
141
142
142
143
r.Get("/keys/{user}", s.Keys)
144
+
r.Get("/terms", s.TermsOfService)
145
+
r.Get("/privacy", s.PrivacyPolicy)
143
146
144
147
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
145
148
s.pages.Error404(w)
···
197
200
return knots.Router(mw)
198
201
}
199
202
203
+
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
204
+
logger := log.New("strings")
205
+
206
+
strs := &avstrings.Strings{
207
+
Db: s.db,
208
+
OAuth: s.oauth,
209
+
Pages: s.pages,
210
+
Config: s.config,
211
+
Enforcer: s.enforcer,
212
+
IdResolver: s.idResolver,
213
+
Knotstream: s.knotstream,
214
+
Logger: logger,
215
+
}
216
+
217
+
return strs.Router(mw)
218
+
}
219
+
200
220
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
201
221
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
202
222
return issues.Router(mw)
···
217
237
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
218
238
return pipes.Router(mw)
219
239
}
240
+
241
+
func (s *State) SignupRouter() http.Handler {
242
+
logger := log.New("signup")
243
+
244
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
245
+
return sig.Router()
246
+
}
+17
-2
appview/state/state.go
+17
-2
appview/state/state.go
···
23
23
"tangled.sh/tangled.sh/core/appview/notify"
24
24
"tangled.sh/tangled.sh/core/appview/oauth"
25
25
"tangled.sh/tangled.sh/core/appview/pages"
26
-
posthog_service "tangled.sh/tangled.sh/core/appview/posthog"
26
+
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
27
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
28
28
"tangled.sh/tangled.sh/core/eventconsumer"
29
29
"tangled.sh/tangled.sh/core/idresolver"
···
93
93
tangled.ActorProfileNSID,
94
94
tangled.SpindleMemberNSID,
95
95
tangled.SpindleNSID,
96
+
tangled.StringNSID,
96
97
},
97
98
nil,
98
99
slog.Default(),
···
133
134
134
135
var notifiers []notify.Notifier
135
136
if !config.Core.Dev {
136
-
notifiers = append(notifiers, posthog_service.NewPosthogNotifier(posthog))
137
+
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
137
138
}
138
139
notifier := notify.NewMergedNotifier(notifiers...)
139
140
···
154
155
}
155
156
156
157
return state, nil
158
+
}
159
+
160
+
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
161
+
user := s.oauth.GetUser(r)
162
+
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
163
+
LoggedInUser: user,
164
+
})
165
+
}
166
+
167
+
func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) {
168
+
user := s.oauth.GetUser(r)
169
+
s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
170
+
LoggedInUser: user,
171
+
})
157
172
}
158
173
159
174
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
+6
appview/state/userutil/userutil.go
+6
appview/state/userutil/userutil.go
···
51
51
func IsDid(s string) bool {
52
52
return didRegex.MatchString(s)
53
53
}
54
+
55
+
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
56
+
57
+
func IsValidSubdomain(name string) bool {
58
+
return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name)
59
+
}
+454
appview/strings/strings.go
+454
appview/strings/strings.go
···
1
+
package strings
2
+
3
+
import (
4
+
"fmt"
5
+
"log/slog"
6
+
"net/http"
7
+
"path"
8
+
"slices"
9
+
"strconv"
10
+
"strings"
11
+
"time"
12
+
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/appview/config"
15
+
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/middleware"
17
+
"tangled.sh/tangled.sh/core/appview/oauth"
18
+
"tangled.sh/tangled.sh/core/appview/pages"
19
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
20
+
"tangled.sh/tangled.sh/core/eventconsumer"
21
+
"tangled.sh/tangled.sh/core/idresolver"
22
+
"tangled.sh/tangled.sh/core/rbac"
23
+
"tangled.sh/tangled.sh/core/tid"
24
+
25
+
"github.com/bluesky-social/indigo/api/atproto"
26
+
"github.com/bluesky-social/indigo/atproto/identity"
27
+
"github.com/bluesky-social/indigo/atproto/syntax"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
29
+
"github.com/go-chi/chi/v5"
30
+
)
31
+
32
+
type Strings struct {
33
+
Db *db.DB
34
+
OAuth *oauth.OAuth
35
+
Pages *pages.Pages
36
+
Config *config.Config
37
+
Enforcer *rbac.Enforcer
38
+
IdResolver *idresolver.Resolver
39
+
Logger *slog.Logger
40
+
Knotstream *eventconsumer.Consumer
41
+
}
42
+
43
+
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
44
+
r := chi.NewRouter()
45
+
46
+
r.
47
+
With(mw.ResolveIdent()).
48
+
Route("/{user}", func(r chi.Router) {
49
+
r.Get("/", s.dashboard)
50
+
51
+
r.Route("/{rkey}", func(r chi.Router) {
52
+
r.Get("/", s.contents)
53
+
r.Delete("/", s.delete)
54
+
r.Get("/raw", s.contents)
55
+
r.Get("/edit", s.edit)
56
+
r.Post("/edit", s.edit)
57
+
r.
58
+
With(middleware.AuthMiddleware(s.OAuth)).
59
+
Post("/comment", s.comment)
60
+
})
61
+
})
62
+
63
+
r.
64
+
With(middleware.AuthMiddleware(s.OAuth)).
65
+
Route("/new", func(r chi.Router) {
66
+
r.Get("/", s.create)
67
+
r.Post("/", s.create)
68
+
})
69
+
70
+
return r
71
+
}
72
+
73
+
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
74
+
l := s.Logger.With("handler", "contents")
75
+
76
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
77
+
if !ok {
78
+
l.Error("malformed middleware")
79
+
w.WriteHeader(http.StatusInternalServerError)
80
+
return
81
+
}
82
+
l = l.With("did", id.DID, "handle", id.Handle)
83
+
84
+
rkey := chi.URLParam(r, "rkey")
85
+
if rkey == "" {
86
+
l.Error("malformed url, empty rkey")
87
+
w.WriteHeader(http.StatusBadRequest)
88
+
return
89
+
}
90
+
l = l.With("rkey", rkey)
91
+
92
+
strings, err := db.GetStrings(
93
+
s.Db,
94
+
db.FilterEq("did", id.DID),
95
+
db.FilterEq("rkey", rkey),
96
+
)
97
+
if err != nil {
98
+
l.Error("failed to fetch string", "err", err)
99
+
w.WriteHeader(http.StatusInternalServerError)
100
+
return
101
+
}
102
+
if len(strings) < 1 {
103
+
l.Error("string not found")
104
+
s.Pages.Error404(w)
105
+
return
106
+
}
107
+
if len(strings) != 1 {
108
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
109
+
w.WriteHeader(http.StatusInternalServerError)
110
+
return
111
+
}
112
+
string := strings[0]
113
+
114
+
if path.Base(r.URL.Path) == "raw" {
115
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
116
+
if string.Filename != "" {
117
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
118
+
}
119
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
120
+
121
+
_, err = w.Write([]byte(string.Contents))
122
+
if err != nil {
123
+
l.Error("failed to write raw response", "err", err)
124
+
}
125
+
return
126
+
}
127
+
128
+
var showRendered, renderToggle bool
129
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
130
+
renderToggle = true
131
+
showRendered = r.URL.Query().Get("code") != "true"
132
+
}
133
+
134
+
s.Pages.SingleString(w, pages.SingleStringParams{
135
+
LoggedInUser: s.OAuth.GetUser(r),
136
+
RenderToggle: renderToggle,
137
+
ShowRendered: showRendered,
138
+
String: string,
139
+
Stats: string.Stats(),
140
+
Owner: id,
141
+
})
142
+
}
143
+
144
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
145
+
l := s.Logger.With("handler", "dashboard")
146
+
147
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
148
+
if !ok {
149
+
l.Error("malformed middleware")
150
+
w.WriteHeader(http.StatusInternalServerError)
151
+
return
152
+
}
153
+
l = l.With("did", id.DID, "handle", id.Handle)
154
+
155
+
all, err := db.GetStrings(
156
+
s.Db,
157
+
db.FilterEq("did", id.DID),
158
+
)
159
+
if err != nil {
160
+
l.Error("failed to fetch strings", "err", err)
161
+
w.WriteHeader(http.StatusInternalServerError)
162
+
return
163
+
}
164
+
165
+
slices.SortFunc(all, func(a, b db.String) int {
166
+
if a.Created.After(b.Created) {
167
+
return -1
168
+
} else {
169
+
return 1
170
+
}
171
+
})
172
+
173
+
profile, err := db.GetProfile(s.Db, id.DID.String())
174
+
if err != nil {
175
+
l.Error("failed to fetch user profile", "err", err)
176
+
w.WriteHeader(http.StatusInternalServerError)
177
+
return
178
+
}
179
+
loggedInUser := s.OAuth.GetUser(r)
180
+
followStatus := db.IsNotFollowing
181
+
if loggedInUser != nil {
182
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
183
+
}
184
+
185
+
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
186
+
if err != nil {
187
+
l.Error("failed to get follow stats", "err", err)
188
+
}
189
+
190
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
191
+
LoggedInUser: s.OAuth.GetUser(r),
192
+
Card: pages.ProfileCard{
193
+
UserDid: id.DID.String(),
194
+
UserHandle: id.Handle.String(),
195
+
Profile: profile,
196
+
FollowStatus: followStatus,
197
+
Followers: followers,
198
+
Following: following,
199
+
},
200
+
Strings: all,
201
+
})
202
+
}
203
+
204
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
205
+
l := s.Logger.With("handler", "edit")
206
+
207
+
user := s.OAuth.GetUser(r)
208
+
209
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
210
+
if !ok {
211
+
l.Error("malformed middleware")
212
+
w.WriteHeader(http.StatusInternalServerError)
213
+
return
214
+
}
215
+
l = l.With("did", id.DID, "handle", id.Handle)
216
+
217
+
rkey := chi.URLParam(r, "rkey")
218
+
if rkey == "" {
219
+
l.Error("malformed url, empty rkey")
220
+
w.WriteHeader(http.StatusBadRequest)
221
+
return
222
+
}
223
+
l = l.With("rkey", rkey)
224
+
225
+
// get the string currently being edited
226
+
all, err := db.GetStrings(
227
+
s.Db,
228
+
db.FilterEq("did", id.DID),
229
+
db.FilterEq("rkey", rkey),
230
+
)
231
+
if err != nil {
232
+
l.Error("failed to fetch string", "err", err)
233
+
w.WriteHeader(http.StatusInternalServerError)
234
+
return
235
+
}
236
+
if len(all) != 1 {
237
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
238
+
w.WriteHeader(http.StatusInternalServerError)
239
+
return
240
+
}
241
+
first := all[0]
242
+
243
+
// verify that the logged in user owns this string
244
+
if user.Did != id.DID.String() {
245
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
246
+
w.WriteHeader(http.StatusUnauthorized)
247
+
return
248
+
}
249
+
250
+
switch r.Method {
251
+
case http.MethodGet:
252
+
// return the form with prefilled fields
253
+
s.Pages.PutString(w, pages.PutStringParams{
254
+
LoggedInUser: s.OAuth.GetUser(r),
255
+
Action: "edit",
256
+
String: first,
257
+
})
258
+
case http.MethodPost:
259
+
fail := func(msg string, err error) {
260
+
l.Error(msg, "err", err)
261
+
s.Pages.Notice(w, "error", msg)
262
+
}
263
+
264
+
filename := r.FormValue("filename")
265
+
if filename == "" {
266
+
fail("Empty filename.", nil)
267
+
return
268
+
}
269
+
if !strings.Contains(filename, ".") {
270
+
// TODO: make this a htmx form validation
271
+
fail("No extension provided for filename.", nil)
272
+
return
273
+
}
274
+
275
+
content := r.FormValue("content")
276
+
if content == "" {
277
+
fail("Empty contents.", nil)
278
+
return
279
+
}
280
+
281
+
description := r.FormValue("description")
282
+
283
+
// construct new string from form values
284
+
entry := db.String{
285
+
Did: first.Did,
286
+
Rkey: first.Rkey,
287
+
Filename: filename,
288
+
Description: description,
289
+
Contents: content,
290
+
Created: first.Created,
291
+
}
292
+
293
+
record := entry.AsRecord()
294
+
295
+
client, err := s.OAuth.AuthorizedClient(r)
296
+
if err != nil {
297
+
fail("Failed to create record.", err)
298
+
return
299
+
}
300
+
301
+
// first replace the existing record in the PDS
302
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
303
+
if err != nil {
304
+
fail("Failed to updated existing record.", err)
305
+
return
306
+
}
307
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
308
+
Collection: tangled.StringNSID,
309
+
Repo: entry.Did.String(),
310
+
Rkey: entry.Rkey,
311
+
SwapRecord: ex.Cid,
312
+
Record: &lexutil.LexiconTypeDecoder{
313
+
Val: &record,
314
+
},
315
+
})
316
+
if err != nil {
317
+
fail("Failed to updated existing record.", err)
318
+
return
319
+
}
320
+
l := l.With("aturi", resp.Uri)
321
+
l.Info("edited string")
322
+
323
+
// if that went okay, updated the db
324
+
if err = db.AddString(s.Db, entry); err != nil {
325
+
fail("Failed to update string.", err)
326
+
return
327
+
}
328
+
329
+
// if that went okay, redir to the string
330
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
331
+
}
332
+
333
+
}
334
+
335
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
336
+
l := s.Logger.With("handler", "create")
337
+
user := s.OAuth.GetUser(r)
338
+
339
+
switch r.Method {
340
+
case http.MethodGet:
341
+
s.Pages.PutString(w, pages.PutStringParams{
342
+
LoggedInUser: s.OAuth.GetUser(r),
343
+
Action: "new",
344
+
})
345
+
case http.MethodPost:
346
+
fail := func(msg string, err error) {
347
+
l.Error(msg, "err", err)
348
+
s.Pages.Notice(w, "error", msg)
349
+
}
350
+
351
+
filename := r.FormValue("filename")
352
+
if filename == "" {
353
+
fail("Empty filename.", nil)
354
+
return
355
+
}
356
+
if !strings.Contains(filename, ".") {
357
+
// TODO: make this a htmx form validation
358
+
fail("No extension provided for filename.", nil)
359
+
return
360
+
}
361
+
362
+
content := r.FormValue("content")
363
+
if content == "" {
364
+
fail("Empty contents.", nil)
365
+
return
366
+
}
367
+
368
+
description := r.FormValue("description")
369
+
370
+
string := db.String{
371
+
Did: syntax.DID(user.Did),
372
+
Rkey: tid.TID(),
373
+
Filename: filename,
374
+
Description: description,
375
+
Contents: content,
376
+
Created: time.Now(),
377
+
}
378
+
379
+
record := string.AsRecord()
380
+
381
+
client, err := s.OAuth.AuthorizedClient(r)
382
+
if err != nil {
383
+
fail("Failed to create record.", err)
384
+
return
385
+
}
386
+
387
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
388
+
Collection: tangled.StringNSID,
389
+
Repo: user.Did,
390
+
Rkey: string.Rkey,
391
+
Record: &lexutil.LexiconTypeDecoder{
392
+
Val: &record,
393
+
},
394
+
})
395
+
if err != nil {
396
+
fail("Failed to create record.", err)
397
+
return
398
+
}
399
+
l := l.With("aturi", resp.Uri)
400
+
l.Info("created record")
401
+
402
+
// insert into DB
403
+
if err = db.AddString(s.Db, string); err != nil {
404
+
fail("Failed to create string.", err)
405
+
return
406
+
}
407
+
408
+
// successful
409
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
410
+
}
411
+
}
412
+
413
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
414
+
l := s.Logger.With("handler", "create")
415
+
user := s.OAuth.GetUser(r)
416
+
fail := func(msg string, err error) {
417
+
l.Error(msg, "err", err)
418
+
s.Pages.Notice(w, "error", msg)
419
+
}
420
+
421
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
422
+
if !ok {
423
+
l.Error("malformed middleware")
424
+
w.WriteHeader(http.StatusInternalServerError)
425
+
return
426
+
}
427
+
l = l.With("did", id.DID, "handle", id.Handle)
428
+
429
+
rkey := chi.URLParam(r, "rkey")
430
+
if rkey == "" {
431
+
l.Error("malformed url, empty rkey")
432
+
w.WriteHeader(http.StatusBadRequest)
433
+
return
434
+
}
435
+
436
+
if user.Did != id.DID.String() {
437
+
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
438
+
return
439
+
}
440
+
441
+
if err := db.DeleteString(
442
+
s.Db,
443
+
db.FilterEq("did", user.Did),
444
+
db.FilterEq("rkey", rkey),
445
+
); err != nil {
446
+
fail("Failed to delete string.", err)
447
+
return
448
+
}
449
+
450
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
451
+
}
452
+
453
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
454
+
}
+3
cmd/gen.go
+3
cmd/gen.go
···
34
34
tangled.Pipeline_PushTriggerData{},
35
35
tangled.PipelineStatus{},
36
36
tangled.Pipeline_Step{},
37
+
tangled.Pipeline_Step_Oidcs_tokens_Elem{},
37
38
tangled.Pipeline_TriggerMetadata{},
38
39
tangled.Pipeline_TriggerRepo{},
39
40
tangled.Pipeline_Workflow{},
40
41
tangled.PublicKey{},
41
42
tangled.Repo{},
42
43
tangled.RepoArtifact{},
44
+
tangled.RepoCollaborator{},
43
45
tangled.RepoIssue{},
44
46
tangled.RepoIssueComment{},
45
47
tangled.RepoIssueState{},
···
49
51
tangled.RepoPullStatus{},
50
52
tangled.Spindle{},
51
53
tangled.SpindleMember{},
54
+
tangled.String{},
52
55
); err != nil {
53
56
panic(err)
54
57
}
+9
-10
docs/hacking.md
+9
-10
docs/hacking.md
···
56
56
`nixosConfiguration` to do so.
57
57
58
58
To begin, head to `http://localhost:3000/knots` in the browser
59
-
and generate a knot secret. Replace the existing secret in
60
-
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61
-
secret.
59
+
and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it,
60
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
61
+
don't lose it.
62
62
63
63
You can now start a lightweight NixOS VM using
64
64
`nixos-shell` like so:
···
91
91
92
92
## running a spindle
93
93
94
-
Be sure to change the `owner` field for the spindle in
95
-
`nix/vm.nix` to your own DID. The above VM should already
96
-
be running a spindle on `localhost:6555`. You can head to
97
-
the spindle dashboard on `http://localhost:3000/spindles`,
98
-
and register a spindle with hostname `localhost:6555`. It
99
-
should instantly be verified. You can then configure each
100
-
repository to use this spindle and run CI jobs.
94
+
Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID.
95
+
The above VM should already be running a spindle on `localhost:6555`.
96
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
97
+
and register a spindle with hostname `localhost:6555`. It should instantly
98
+
be verified. You can then configure each repository to use this spindle
99
+
and run CI jobs.
101
100
102
101
Of interest when debugging spindles:
103
102
+13
-1
docs/knot-hosting.md
+13
-1
docs/knot-hosting.md
···
89
89
systemctl start knotserver
90
90
```
91
91
92
-
The last step is to configure a reverse proxy like Nginx or Caddy to front yourself
92
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
93
93
knot. Here's an example configuration for Nginx:
94
94
95
95
```
···
191
191
```
192
192
193
193
Make sure to restart your SSH server!
194
+
195
+
#### MOTD (message of the day)
196
+
197
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
198
+
`/home/git/motd` file:
199
+
200
+
```
201
+
printf "Hi from this knot!\n" > /home/git/motd
202
+
```
203
+
204
+
Note that you should add a newline at the end if setting a non-empty message
205
+
since the knot won't do this for you.
+193
-38
docs/spindle/openbao.md
+193
-38
docs/spindle/openbao.md
···
1
1
# spindle secrets with openbao
2
2
3
3
This document covers setting up Spindle to use OpenBao for secrets
4
-
management instead of the default SQLite backend.
4
+
management via OpenBao Proxy instead of the default SQLite backend.
5
+
6
+
## overview
7
+
8
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
+
authentication automatically using AppRole credentials, while Spindle
10
+
connects to the local proxy instead of directly to the OpenBao server.
11
+
12
+
This approach provides better security, automatic token renewal, and
13
+
simplified application code.
5
14
6
15
## installation
7
16
8
17
Install OpenBao from nixpkgs:
9
18
10
19
```bash
11
-
nix-env -iA nixpkgs.openbao
20
+
nix shell nixpkgs#openbao # for a local server
12
21
```
13
22
14
-
## local development setup
23
+
## setup
24
+
25
+
The setup process can is documented for both local development and production.
26
+
27
+
### local development
15
28
16
29
Start OpenBao in dev mode:
17
30
18
31
```bash
19
-
bao server -dev
32
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
20
33
```
21
34
22
-
This starts OpenBao on `http://localhost:8200` with a root token. Save
23
-
the root token from the output -- you'll need it.
35
+
This starts OpenBao on `http://localhost:8201` with a root token.
24
36
25
37
Set up environment for bao CLI:
26
38
27
39
```bash
28
40
export BAO_ADDR=http://localhost:8200
29
-
export BAO_TOKEN=hvs.your-root-token-here
41
+
export BAO_TOKEN=root
30
42
```
31
43
44
+
### production
45
+
46
+
You would typically use a systemd service with a configuration file. Refer to
47
+
[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
48
+
achieved using Nix.
49
+
50
+
Then, initialize the bao server:
51
+
```bash
52
+
bao operator init -key-shares=1 -key-threshold=1
53
+
```
54
+
55
+
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
56
+
```bash
57
+
bao operator unseal <unseal_key>
58
+
```
59
+
60
+
All steps below remain the same across both dev and production setups.
61
+
62
+
### configure openbao server
63
+
32
64
Create the spindle KV mount:
33
65
34
66
```bash
35
67
bao secrets enable -path=spindle -version=2 kv
36
68
```
37
69
38
-
Set up AppRole authentication:
70
+
Set up AppRole authentication and policy:
39
71
40
72
Create a policy file `spindle-policy.hcl`:
41
73
42
74
```hcl
75
+
# Full access to spindle KV v2 data
43
76
path "spindle/data/*" {
44
-
capabilities = ["create", "read", "update", "delete", "list"]
77
+
capabilities = ["create", "read", "update", "delete"]
45
78
}
46
79
80
+
# Access to metadata for listing and management
47
81
path "spindle/metadata/*" {
48
-
capabilities = ["list", "read", "delete"]
82
+
capabilities = ["list", "read", "delete", "update"]
49
83
}
50
84
51
-
path "spindle/*" {
85
+
# Allow listing at root level
86
+
path "spindle/" {
52
87
capabilities = ["list"]
53
88
}
89
+
90
+
# Required for connection testing and health checks
91
+
path "auth/token/lookup-self" {
92
+
capabilities = ["read"]
93
+
}
54
94
```
55
95
56
96
Apply the policy and create an AppRole:
···
61
101
bao write auth/approle/role/spindle \
62
102
token_policies="spindle-policy" \
63
103
token_ttl=1h \
64
-
token_max_ttl=4h
104
+
token_max_ttl=4h \
105
+
bind_secret_id=true \
106
+
secret_id_ttl=0 \
107
+
secret_id_num_uses=0
65
108
```
66
109
67
110
Get the credentials:
68
111
69
112
```bash
70
-
bao read auth/approle/role/spindle/role-id
71
-
bao write -f auth/approle/role/spindle/secret-id
113
+
# Get role ID (static)
114
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
+
116
+
# Generate secret ID
117
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
+
119
+
echo "Role ID: $ROLE_ID"
120
+
echo "Secret ID: $SECRET_ID"
121
+
```
122
+
123
+
### create proxy configuration
124
+
125
+
Create the credential files:
126
+
127
+
```bash
128
+
# Create directory for OpenBao files
129
+
mkdir -p /tmp/openbao
130
+
131
+
# Save credentials
132
+
echo "$ROLE_ID" > /tmp/openbao/role-id
133
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
+
```
136
+
137
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
+
139
+
```hcl
140
+
# OpenBao server connection
141
+
vault {
142
+
address = "http://localhost:8200"
143
+
}
144
+
145
+
# Auto-Auth using AppRole
146
+
auto_auth {
147
+
method "approle" {
148
+
mount_path = "auth/approle"
149
+
config = {
150
+
role_id_file_path = "/tmp/openbao/role-id"
151
+
secret_id_file_path = "/tmp/openbao/secret-id"
152
+
}
153
+
}
154
+
155
+
# Optional: write token to file for debugging
156
+
sink "file" {
157
+
config = {
158
+
path = "/tmp/openbao/token"
159
+
mode = 0640
160
+
}
161
+
}
162
+
}
163
+
164
+
# Proxy listener for Spindle
165
+
listener "tcp" {
166
+
address = "127.0.0.1:8201"
167
+
tls_disable = true
168
+
}
169
+
170
+
# Enable API proxy with auto-auth token
171
+
api_proxy {
172
+
use_auto_auth_token = true
173
+
}
174
+
175
+
# Enable response caching
176
+
cache {
177
+
use_auto_auth_token = true
178
+
}
179
+
180
+
# Logging
181
+
log_level = "info"
72
182
```
73
183
74
-
Configure Spindle:
184
+
### start the proxy
185
+
186
+
Start OpenBao Proxy:
187
+
188
+
```bash
189
+
bao proxy -config=/tmp/openbao/proxy.hcl
190
+
```
191
+
192
+
The proxy will authenticate with OpenBao and start listening on
193
+
`127.0.0.1:8201`.
194
+
195
+
### configure spindle
75
196
76
197
Set these environment variables for Spindle:
77
198
78
199
```bash
79
200
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
80
-
export SPINDLE_SERVER_SECRETS_OPENBAO_ADDR=http://localhost:8200
81
-
export SPINDLE_SERVER_SECRETS_OPENBAO_ROLE_ID=your-role-id-from-above
82
-
export SPINDLE_SERVER_SECRETS_OPENBAO_SECRET_ID=your-secret-id-from-above
201
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
83
202
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
84
203
```
85
204
86
205
Start Spindle:
87
206
88
-
Spindle will now use OpenBao for secrets storage with automatic token
89
-
renewal.
207
+
Spindle will now connect to the local proxy, which handles all
208
+
authentication automatically.
209
+
210
+
## production setup for proxy
211
+
212
+
For production, you'll want to run the proxy as a service:
213
+
214
+
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
+
proper TLS settings for the vault connection.
90
216
91
217
## verifying setup
92
218
93
-
List all secrets:
219
+
Test the proxy directly:
94
220
95
221
```bash
96
-
bao kv list spindle/
222
+
# Check proxy health
223
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
+
225
+
# Test token lookup through proxy
226
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
97
227
```
98
228
99
-
Add a test secret via Spindle API, then check it exists:
229
+
Test OpenBao operations through the server:
100
230
101
231
```bash
232
+
# List all secrets
233
+
bao kv list spindle/
234
+
235
+
# Add a test secret via Spindle API, then check it exists
102
236
bao kv list spindle/repos/
103
-
```
104
237
105
-
Get a specific secret:
106
-
107
-
```bash
238
+
# Get a specific secret
108
239
bao kv get spindle/repos/your_repo_path/SECRET_NAME
109
240
```
110
241
111
242
## how it works
112
243
244
+
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
+
- The proxy authenticates with OpenBao using AppRole credentials
246
+
- All Spindle requests go through the proxy, which injects authentication tokens
113
247
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
114
-
- Each repository gets its own namespace
115
-
- Repository paths like `at://did:plc:alice/myrepo` become
116
-
`at_did_plc_alice_myrepo`
117
-
- The system automatically handles token renewal using AppRole
118
-
authentication
119
-
- On shutdown, Spindle cleanly stops the token renewal process
248
+
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
+
- The proxy handles all token renewal automatically
250
+
- Spindle no longer manages tokens or authentication directly
120
251
121
252
## troubleshooting
122
253
123
-
**403 errors**: Check that your BAO_TOKEN is set and the spindle mount
124
-
exists
254
+
**Connection refused**: Check that the OpenBao Proxy is running and
255
+
listening on the configured address.
256
+
257
+
**403 errors**: Verify the AppRole credentials are correct and the policy
258
+
has the necessary permissions.
125
259
126
260
**404 route errors**: The spindle KV mount probably doesn't exist - run
127
-
the mount creation step again
261
+
the mount creation step again.
128
262
129
-
**Token expired**: The AppRole system should handle this automatically,
130
-
but you can check token status with `bao token lookup`
263
+
**Proxy authentication failures**: Check the proxy logs and verify the
264
+
role-id and secret-id files are readable and contain valid credentials.
265
+
266
+
**Secret not found after writing**: This can indicate policy permission
267
+
issues. Verify the policy includes both `spindle/data/*` and
268
+
`spindle/metadata/*` paths with appropriate capabilities.
269
+
270
+
Check proxy logs:
271
+
272
+
```bash
273
+
# If running as systemd service
274
+
journalctl -u openbao-proxy -f
275
+
276
+
# If running directly, check the console output
277
+
```
278
+
279
+
Test AppRole authentication manually:
280
+
281
+
```bash
282
+
bao write auth/approle/login \
283
+
role_id="$(cat /tmp/openbao/role-id)" \
284
+
secret_id="$(cat /tmp/openbao/secret-id)"
285
+
```
+7
-28
flake.lock
+7
-28
flake.lock
···
1
1
{
2
2
"nodes": {
3
-
"gitignore": {
4
-
"inputs": {
5
-
"nixpkgs": [
6
-
"nixpkgs"
7
-
]
8
-
},
9
-
"locked": {
10
-
"lastModified": 1709087332,
11
-
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
12
-
"owner": "hercules-ci",
13
-
"repo": "gitignore.nix",
14
-
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
15
-
"type": "github"
16
-
},
17
-
"original": {
18
-
"owner": "hercules-ci",
19
-
"repo": "gitignore.nix",
20
-
"type": "github"
21
-
}
22
-
},
23
3
"flake-utils": {
24
4
"inputs": {
25
5
"systems": "systems"
···
99
79
"indigo": {
100
80
"flake": false,
101
81
"locked": {
102
-
"lastModified": 1745333930,
103
-
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
82
+
"lastModified": 1753693716,
83
+
"narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=",
104
84
"owner": "oppiliappan",
105
85
"repo": "indigo",
106
-
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
86
+
"rev": "5f170569da9360f57add450a278d73538092d8ca",
107
87
"type": "github"
108
88
},
109
89
"original": {
···
128
108
"lucide-src": {
129
109
"flake": false,
130
110
"locked": {
131
-
"lastModified": 1742302029,
132
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
111
+
"lastModified": 1754044466,
112
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
133
113
"type": "tarball",
134
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
114
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
135
115
},
136
116
"original": {
137
117
"type": "tarball",
138
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
118
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
139
119
}
140
120
},
141
121
"nixpkgs": {
···
156
136
},
157
137
"root": {
158
138
"inputs": {
159
-
"gitignore": "gitignore",
160
139
"gomod2nix": "gomod2nix",
161
140
"htmx-src": "htmx-src",
162
141
"htmx-ws-src": "htmx-ws-src",
+75
-27
flake.nix
+75
-27
flake.nix
···
22
22
flake = false;
23
23
};
24
24
lucide-src = {
25
-
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
25
+
url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip";
26
26
flake = false;
27
27
};
28
28
inter-fonts-src = {
···
37
37
url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip";
38
38
flake = false;
39
39
};
40
-
gitignore = {
41
-
url = "github:hercules-ci/gitignore.nix";
42
-
inputs.nixpkgs.follows = "nixpkgs";
43
-
};
44
40
};
45
41
46
42
outputs = {
···
51
47
htmx-src,
52
48
htmx-ws-src,
53
49
lucide-src,
54
-
gitignore,
55
50
inter-fonts-src,
56
51
sqlite-lib-src,
57
52
ibm-plex-mono-src,
···
62
57
63
58
mkPackageSet = pkgs:
64
59
pkgs.lib.makeScope pkgs.newScope (self: {
65
-
inherit (gitignore.lib) gitignoreSource;
60
+
src = let
61
+
fs = pkgs.lib.fileset;
62
+
in
63
+
fs.toSource {
64
+
root = ./.;
65
+
fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj);
66
+
};
66
67
buildGoApplication =
67
68
(self.callPackage "${gomod2nix}/builder" {
68
69
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
···
74
75
};
75
76
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
76
77
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
77
-
appview = self.callPackage ./nix/pkgs/appview.nix {
78
+
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
78
79
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
79
80
};
81
+
appview = self.callPackage ./nix/pkgs/appview.nix {};
80
82
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
81
83
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
82
84
knot = self.callPackage ./nix/pkgs/knot.nix {};
···
92
94
staticPackages = mkPackageSet pkgs.pkgsStatic;
93
95
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
94
96
in {
95
-
appview = packages.appview;
96
-
lexgen = packages.lexgen;
97
-
knot = packages.knot;
98
-
knot-unwrapped = packages.knot-unwrapped;
99
-
spindle = packages.spindle;
100
-
genjwks = packages.genjwks;
101
-
sqlite-lib = packages.sqlite-lib;
97
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
102
98
103
99
pkgsStatic-appview = staticPackages.appview;
104
100
pkgsStatic-knot = staticPackages.knot;
···
120
116
stdenv = pkgs.pkgsStatic.stdenv;
121
117
};
122
118
in {
123
-
default = staticShell {
119
+
default = pkgs.mkShell {
124
120
nativeBuildInputs = [
125
121
pkgs.go
126
122
pkgs.air
···
131
127
pkgs.tailwindcss
132
128
pkgs.nixos-shell
133
129
pkgs.redis
130
+
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
134
131
packages'.lexgen
135
132
];
136
133
shellHook = ''
137
-
mkdir -p appview/pages/static/{fonts,icons}
138
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
139
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
140
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
141
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
142
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
143
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
134
+
mkdir -p appview/pages/static
135
+
# no preserve is needed because watch-tailwind will want to be able to overwrite
136
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
144
137
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
145
138
'';
146
139
env.CGO_ENABLED = 1;
···
148
141
});
149
142
apps = forAllSystems (system: let
150
143
pkgs = nixpkgsFor."${system}";
144
+
packages' = self.packages.${system};
151
145
air-watcher = name: arg:
152
146
pkgs.writeShellScriptBin "run"
153
147
''
···
166
160
in {
167
161
watch-appview = {
168
162
type = "app";
169
-
program = ''${air-watcher "appview" ""}/bin/run'';
163
+
program = toString (pkgs.writeShellScript "watch-appview" ''
164
+
echo "copying static files to appview/pages/static..."
165
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
166
+
${air-watcher "appview" ""}/bin/run
167
+
'');
170
168
};
171
169
watch-knot = {
172
170
type = "app";
···
176
174
type = "app";
177
175
program = ''${tailwind-watcher}/bin/run'';
178
176
};
179
-
vm = {
177
+
vm = let
178
+
system =
179
+
if pkgs.stdenv.hostPlatform.isAarch64
180
+
then "aarch64"
181
+
else "x86_64";
182
+
183
+
nixos-shell = pkgs.nixos-shell.overrideAttrs (old: {
184
+
patches =
185
+
(old.patches or [])
186
+
++ [
187
+
# https://github.com/Mic92/nixos-shell/pull/94
188
+
(pkgs.fetchpatch {
189
+
name = "fix-foreign-vm.patch";
190
+
url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch";
191
+
hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo=";
192
+
})
193
+
];
194
+
});
195
+
in {
180
196
type = "app";
181
197
program = toString (pkgs.writeShellScript "vm" ''
182
-
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
198
+
${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux
183
199
'');
184
200
};
185
201
gomod2nix = {
···
188
204
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
189
205
'');
190
206
};
207
+
lexgen = {
208
+
type = "app";
209
+
program =
210
+
(pkgs.writeShellApplication {
211
+
name = "lexgen";
212
+
text = ''
213
+
if ! command -v lexgen > /dev/null; then
214
+
echo "error: must be executed from devshell"
215
+
exit 1
216
+
fi
217
+
218
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
219
+
cd "$rootDir"
220
+
221
+
rm api/tangled/*
222
+
lexgen --build-file lexicon-build-config.json lexicons
223
+
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
224
+
${pkgs.gotools}/bin/goimports -w api/tangled/*
225
+
go run cmd/gen.go
226
+
lexgen --build-file lexicon-build-config.json lexicons
227
+
rm api/tangled/*.bak
228
+
'';
229
+
})
230
+
+ /bin/lexgen;
231
+
};
191
232
});
192
233
193
234
nixosModules.appview = {
···
217
258
218
259
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
219
260
};
220
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
261
+
nixosConfigurations.vm-x86_64 = import ./nix/vm.nix {
262
+
inherit self nixpkgs;
263
+
system = "x86_64-linux";
264
+
};
265
+
nixosConfigurations.vm-aarch64 = import ./nix/vm.nix {
266
+
inherit self nixpkgs;
267
+
system = "aarch64-linux";
268
+
};
221
269
};
222
270
}
+2
go.mod
+2
go.mod
···
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/carlmjohnson/versioninfo v0.22.5
14
14
github.com/casbin/casbin/v2 v2.103.0
15
+
github.com/cloudflare/cloudflare-go v0.115.0
15
16
github.com/cyphar/filepath-securejoin v0.4.1
16
17
github.com/dgraph-io/ristretto v0.2.0
17
18
github.com/docker/docker v28.2.2+incompatible
···
85
86
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
86
87
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
87
88
github.com/golang/mock v1.6.0 // indirect
89
+
github.com/google/go-querystring v1.1.0 // indirect
88
90
github.com/gorilla/css v1.0.1 // indirect
89
91
github.com/gorilla/securecookie v1.1.2 // indirect
90
92
github.com/hashicorp/errwrap v1.1.0 // indirect
+5
go.sum
+5
go.sum
···
53
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
54
54
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4=
55
55
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
56
+
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
57
+
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
56
58
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
57
59
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
58
60
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
152
154
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
153
155
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
154
156
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
157
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
155
158
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
156
159
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
157
160
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
158
161
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
159
162
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
163
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
164
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
160
165
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
161
166
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
162
167
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+4
input.css
+4
input.css
+13
jetstream/jetstream.go
+13
jetstream/jetstream.go
···
52
52
j.mu.Unlock()
53
53
}
54
54
55
+
func (j *JetstreamClient) RemoveDid(did string) {
56
+
if did == "" {
57
+
return
58
+
}
59
+
60
+
if j.logDids {
61
+
j.l.Info("removing did from in-memory filter", "did", did)
62
+
}
63
+
j.mu.Lock()
64
+
delete(j.wantedDids, did)
65
+
j.mu.Unlock()
66
+
}
67
+
55
68
type processor func(context.Context, *models.Event) error
56
69
57
70
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
-8
knotserver/file.go
-8
knotserver/file.go
···
10
10
"tangled.sh/tangled.sh/core/types"
11
11
)
12
12
13
-
func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) {
14
-
data["files"] = files
15
-
16
-
writeJSON(w, data)
17
-
return
18
-
}
19
-
20
13
func countLines(r io.Reader) (int, error) {
21
14
buf := make([]byte, 32*1024)
22
15
bufLen := 0
···
52
45
53
46
resp.Lines = lc
54
47
writeJSON(w, resp)
55
-
return
56
48
}
+19
-12
knotserver/git/post_receive.go
+19
-12
knotserver/git/post_receive.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"strings"
···
57
58
ByEmail map[string]int
58
59
}
59
60
60
-
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
61
+
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) {
62
+
var errs error
63
+
61
64
commitCount, err := g.newCommitCount(line)
62
-
if err != nil {
63
-
// TODO: log this
64
-
}
65
+
errors.Join(errs, err)
65
66
66
67
isDefaultRef, err := g.isDefaultBranch(line)
67
-
if err != nil {
68
-
// TODO: log this
69
-
}
68
+
errors.Join(errs, err)
70
69
71
70
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
72
71
defer cancel()
73
72
breakdown, err := g.AnalyzeLanguages(ctx)
74
-
if err != nil {
75
-
// TODO: log this
76
-
}
73
+
errors.Join(errs, err)
77
74
78
75
return RefUpdateMeta{
79
76
CommitCount: commitCount,
80
77
IsDefaultRef: isDefaultRef,
81
78
LangBreakdown: breakdown,
82
-
}
79
+
}, errs
83
80
}
84
81
85
82
func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) {
···
95
92
args := []string{fmt.Sprintf("--max-count=%d", 100)}
96
93
97
94
if line.OldSha.IsZero() {
98
-
// just git rev-list <newsha>
95
+
// git rev-list <newsha> ^other-branches --not ^this-branch
99
96
args = append(args, line.NewSha.String())
97
+
98
+
branches, _ := g.Branches()
99
+
for _, b := range branches {
100
+
if !strings.Contains(line.Ref, b.Name) {
101
+
args = append(args, fmt.Sprintf("^%s", b.Name))
102
+
}
103
+
}
104
+
105
+
args = append(args, "--not")
106
+
args = append(args, fmt.Sprintf("^%s", line.Ref))
100
107
} else {
101
108
// git rev-list <oldsha>..<newsha>
102
109
args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+61
-1
knotserver/ingester.go
+61
-1
knotserver/ingester.go
···
213
213
return h.db.InsertEvent(event, h.n)
214
214
}
215
215
216
+
// duplicated from add collaborator
217
+
func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error {
218
+
repoAt, err := syntax.ParseATURI(record.Repo)
219
+
if err != nil {
220
+
return err
221
+
}
222
+
223
+
resolver := idresolver.DefaultResolver()
224
+
225
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
227
+
return err
228
+
}
229
+
230
+
// TODO: fix this for good, we need to fetch the record here unfortunately
231
+
// resolve this aturi to extract the repo record
232
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
233
+
if err != nil || owner.Handle.IsInvalidHandle() {
234
+
return fmt.Errorf("failed to resolve handle: %w", err)
235
+
}
236
+
237
+
xrpcc := xrpc.Client{
238
+
Host: owner.PDSEndpoint(),
239
+
}
240
+
241
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
242
+
if err != nil {
243
+
return err
244
+
}
245
+
246
+
repo := resp.Value.Val.(*tangled.Repo)
247
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
248
+
249
+
// check perms for this user
250
+
if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
251
+
return fmt.Errorf("insufficient permissions: %w", err)
252
+
}
253
+
254
+
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
255
+
return err
256
+
}
257
+
h.jc.AddDid(subjectId.DID.String())
258
+
259
+
if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil {
260
+
return err
261
+
}
262
+
263
+
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
264
+
}
265
+
216
266
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
217
267
l := log.FromContext(ctx)
218
268
···
266
316
defer func() {
267
317
eventTime := event.TimeUS
268
318
lastTimeUs := eventTime + 1
269
-
fmt.Println("lastTimeUs", lastTimeUs)
270
319
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
271
320
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
272
321
}
···
292
341
if err := h.processKnotMember(ctx, did, record); err != nil {
293
342
return fmt.Errorf("failed to process knot member: %w", err)
294
343
}
344
+
295
345
case tangled.RepoPullNSID:
296
346
var record tangled.RepoPull
297
347
if err := json.Unmarshal(raw, &record); err != nil {
···
300
350
if err := h.processPull(ctx, did, record); err != nil {
301
351
return fmt.Errorf("failed to process knot member: %w", err)
302
352
}
353
+
354
+
case tangled.RepoCollaboratorNSID:
355
+
var record tangled.RepoCollaborator
356
+
if err := json.Unmarshal(raw, &record); err != nil {
357
+
return fmt.Errorf("failed to unmarshal record: %w", err)
358
+
}
359
+
if err := h.processCollaborator(ctx, did, record); err != nil {
360
+
return fmt.Errorf("failed to process knot member: %w", err)
361
+
}
362
+
303
363
}
304
364
305
365
return err
+5
-2
knotserver/internal.go
+5
-2
knotserver/internal.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"log/slog"
8
9
"net/http"
···
145
146
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
146
147
}
147
148
148
-
meta := gr.RefUpdateMeta(line)
149
+
var errs error
150
+
meta, err := gr.RefUpdateMeta(line)
151
+
errors.Join(errs, err)
149
152
150
153
metaRecord := meta.AsRecord()
151
154
···
169
172
EventJson: string(eventJson),
170
173
}
171
174
172
-
return h.db.InsertEvent(event, h.n)
175
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
173
176
}
174
177
175
178
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
+1
knotserver/server.go
+1
knotserver/server.go
-37
lexicons/addSecret.json
-37
lexicons/addSecret.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.addSecret",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Add a CI secret",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"key",
15
-
"value"
16
-
],
17
-
"properties": {
18
-
"repo": {
19
-
"type": "string",
20
-
"format": "at-uri"
21
-
},
22
-
"key": {
23
-
"type": "string",
24
-
"maxLength": 50,
25
-
"minLength": 1
26
-
},
27
-
"value": {
28
-
"type": "string",
29
-
"maxLength": 200,
30
-
"minLength": 1
31
-
}
32
-
}
33
-
}
34
-
}
35
-
}
36
-
}
37
-
}
-52
lexicons/artifact.json
-52
lexicons/artifact.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.artifact",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"name",
14
-
"repo",
15
-
"tag",
16
-
"createdAt",
17
-
"artifact"
18
-
],
19
-
"properties": {
20
-
"name": {
21
-
"type": "string",
22
-
"description": "name of the artifact"
23
-
},
24
-
"repo": {
25
-
"type": "string",
26
-
"format": "at-uri",
27
-
"description": "repo that this artifact is being uploaded to"
28
-
},
29
-
"tag": {
30
-
"type": "bytes",
31
-
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
32
-
"minLength": 20,
33
-
"maxLength": 20
34
-
},
35
-
"createdAt": {
36
-
"type": "string",
37
-
"format": "datetime",
38
-
"description": "time of creation of this artifact"
39
-
},
40
-
"artifact": {
41
-
"type": "blob",
42
-
"description": "the artifact",
43
-
"accept": [
44
-
"*/*"
45
-
],
46
-
"maxSize": 52428800
47
-
}
48
-
}
49
-
}
50
-
}
51
-
}
52
-
}
-29
lexicons/defaultBranch.json
-29
lexicons/defaultBranch.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.setDefaultBranch",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Set the default branch for a repository",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"defaultBranch"
15
-
],
16
-
"properties": {
17
-
"repo": {
18
-
"type": "string",
19
-
"format": "at-uri"
20
-
},
21
-
"defaultBranch": {
22
-
"type": "string"
23
-
}
24
-
}
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
-67
lexicons/listSecrets.json
-67
lexicons/listSecrets.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.listSecrets",
4
-
"defs": {
5
-
"main": {
6
-
"type": "query",
7
-
"parameters": {
8
-
"type": "params",
9
-
"required": [
10
-
"repo"
11
-
],
12
-
"properties": {
13
-
"repo": {
14
-
"type": "string",
15
-
"format": "at-uri"
16
-
}
17
-
}
18
-
},
19
-
"output": {
20
-
"encoding": "application/json",
21
-
"schema": {
22
-
"type": "object",
23
-
"required": [
24
-
"secrets"
25
-
],
26
-
"properties": {
27
-
"secrets": {
28
-
"type": "array",
29
-
"items": {
30
-
"type": "ref",
31
-
"ref": "#secret"
32
-
}
33
-
}
34
-
}
35
-
}
36
-
}
37
-
},
38
-
"secret": {
39
-
"type": "object",
40
-
"required": [
41
-
"repo",
42
-
"key",
43
-
"createdAt",
44
-
"createdBy"
45
-
],
46
-
"properties": {
47
-
"repo": {
48
-
"type": "string",
49
-
"format": "at-uri"
50
-
},
51
-
"key": {
52
-
"type": "string",
53
-
"maxLength": 50,
54
-
"minLength": 1
55
-
},
56
-
"createdAt": {
57
-
"type": "string",
58
-
"format": "datetime"
59
-
},
60
-
"createdBy": {
61
-
"type": "string",
62
-
"format": "did"
63
-
}
64
-
}
65
-
}
66
-
}
67
-
}
+280
lexicons/pipeline/pipeline.json
+280
lexicons/pipeline/pipeline.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.pipeline",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"triggerMetadata",
14
+
"workflows"
15
+
],
16
+
"properties": {
17
+
"triggerMetadata": {
18
+
"type": "ref",
19
+
"ref": "#triggerMetadata"
20
+
},
21
+
"workflows": {
22
+
"type": "array",
23
+
"items": {
24
+
"type": "ref",
25
+
"ref": "#workflow"
26
+
}
27
+
}
28
+
}
29
+
}
30
+
},
31
+
"triggerMetadata": {
32
+
"type": "object",
33
+
"required": [
34
+
"kind",
35
+
"repo"
36
+
],
37
+
"properties": {
38
+
"kind": {
39
+
"type": "string",
40
+
"enum": [
41
+
"push",
42
+
"pull_request",
43
+
"manual"
44
+
]
45
+
},
46
+
"repo": {
47
+
"type": "ref",
48
+
"ref": "#triggerRepo"
49
+
},
50
+
"push": {
51
+
"type": "ref",
52
+
"ref": "#pushTriggerData"
53
+
},
54
+
"pullRequest": {
55
+
"type": "ref",
56
+
"ref": "#pullRequestTriggerData"
57
+
},
58
+
"manual": {
59
+
"type": "ref",
60
+
"ref": "#manualTriggerData"
61
+
}
62
+
}
63
+
},
64
+
"triggerRepo": {
65
+
"type": "object",
66
+
"required": [
67
+
"knot",
68
+
"did",
69
+
"repo",
70
+
"defaultBranch"
71
+
],
72
+
"properties": {
73
+
"knot": {
74
+
"type": "string"
75
+
},
76
+
"did": {
77
+
"type": "string",
78
+
"format": "did"
79
+
},
80
+
"repo": {
81
+
"type": "string"
82
+
},
83
+
"defaultBranch": {
84
+
"type": "string"
85
+
}
86
+
}
87
+
},
88
+
"pushTriggerData": {
89
+
"type": "object",
90
+
"required": [
91
+
"ref",
92
+
"newSha",
93
+
"oldSha"
94
+
],
95
+
"properties": {
96
+
"ref": {
97
+
"type": "string"
98
+
},
99
+
"newSha": {
100
+
"type": "string",
101
+
"minLength": 40,
102
+
"maxLength": 40
103
+
},
104
+
"oldSha": {
105
+
"type": "string",
106
+
"minLength": 40,
107
+
"maxLength": 40
108
+
}
109
+
}
110
+
},
111
+
"pullRequestTriggerData": {
112
+
"type": "object",
113
+
"required": [
114
+
"sourceBranch",
115
+
"targetBranch",
116
+
"sourceSha",
117
+
"action"
118
+
],
119
+
"properties": {
120
+
"sourceBranch": {
121
+
"type": "string"
122
+
},
123
+
"targetBranch": {
124
+
"type": "string"
125
+
},
126
+
"sourceSha": {
127
+
"type": "string",
128
+
"minLength": 40,
129
+
"maxLength": 40
130
+
},
131
+
"action": {
132
+
"type": "string"
133
+
}
134
+
}
135
+
},
136
+
"manualTriggerData": {
137
+
"type": "object",
138
+
"properties": {
139
+
"inputs": {
140
+
"type": "array",
141
+
"items": {
142
+
"type": "ref",
143
+
"ref": "#pair"
144
+
}
145
+
}
146
+
}
147
+
},
148
+
"workflow": {
149
+
"type": "object",
150
+
"required": [
151
+
"name",
152
+
"dependencies",
153
+
"steps",
154
+
"environment",
155
+
"clone"
156
+
],
157
+
"properties": {
158
+
"name": {
159
+
"type": "string"
160
+
},
161
+
"dependencies": {
162
+
"type": "array",
163
+
"items": {
164
+
"type": "ref",
165
+
"ref": "#dependency"
166
+
}
167
+
},
168
+
"steps": {
169
+
"type": "array",
170
+
"items": {
171
+
"type": "ref",
172
+
"ref": "#step"
173
+
}
174
+
},
175
+
"environment": {
176
+
"type": "array",
177
+
"items": {
178
+
"type": "ref",
179
+
"ref": "#pair"
180
+
}
181
+
},
182
+
"clone": {
183
+
"type": "ref",
184
+
"ref": "#cloneOpts"
185
+
}
186
+
}
187
+
},
188
+
"dependency": {
189
+
"type": "object",
190
+
"required": [
191
+
"registry",
192
+
"packages"
193
+
],
194
+
"properties": {
195
+
"registry": {
196
+
"type": "string"
197
+
},
198
+
"packages": {
199
+
"type": "array",
200
+
"items": {
201
+
"type": "string"
202
+
}
203
+
}
204
+
}
205
+
},
206
+
"cloneOpts": {
207
+
"type": "object",
208
+
"required": [
209
+
"skip",
210
+
"depth",
211
+
"submodules"
212
+
],
213
+
"properties": {
214
+
"skip": {
215
+
"type": "boolean"
216
+
},
217
+
"depth": {
218
+
"type": "integer"
219
+
},
220
+
"submodules": {
221
+
"type": "boolean"
222
+
}
223
+
}
224
+
},
225
+
"step": {
226
+
"type": "object",
227
+
"required": [
228
+
"name",
229
+
"command"
230
+
],
231
+
"properties": {
232
+
"name": {
233
+
"type": "string"
234
+
},
235
+
"command": {
236
+
"type": "string"
237
+
},
238
+
"environment": {
239
+
"type": "array",
240
+
"items": {
241
+
"type": "ref",
242
+
"ref": "#pair"
243
+
}
244
+
},
245
+
"oidcs_tokens": {
246
+
"type": "array",
247
+
"items": {
248
+
"type": "object",
249
+
"required": [
250
+
"name"
251
+
],
252
+
"properties": {
253
+
"name": {
254
+
"type": "string"
255
+
},
256
+
"aud": {
257
+
"type": "string"
258
+
}
259
+
}
260
+
}
261
+
}
262
+
}
263
+
},
264
+
"pair": {
265
+
"type": "object",
266
+
"required": [
267
+
"key",
268
+
"value"
269
+
],
270
+
"properties": {
271
+
"key": {
272
+
"type": "string"
273
+
},
274
+
"value": {
275
+
"type": "string"
276
+
}
277
+
}
278
+
}
279
+
}
280
+
}
-263
lexicons/pipeline.json
-263
lexicons/pipeline.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.pipeline",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"triggerMetadata",
14
-
"workflows"
15
-
],
16
-
"properties": {
17
-
"triggerMetadata": {
18
-
"type": "ref",
19
-
"ref": "#triggerMetadata"
20
-
},
21
-
"workflows": {
22
-
"type": "array",
23
-
"items": {
24
-
"type": "ref",
25
-
"ref": "#workflow"
26
-
}
27
-
}
28
-
}
29
-
}
30
-
},
31
-
"triggerMetadata": {
32
-
"type": "object",
33
-
"required": [
34
-
"kind",
35
-
"repo"
36
-
],
37
-
"properties": {
38
-
"kind": {
39
-
"type": "string",
40
-
"enum": [
41
-
"push",
42
-
"pull_request",
43
-
"manual"
44
-
]
45
-
},
46
-
"repo": {
47
-
"type": "ref",
48
-
"ref": "#triggerRepo"
49
-
},
50
-
"push": {
51
-
"type": "ref",
52
-
"ref": "#pushTriggerData"
53
-
},
54
-
"pullRequest": {
55
-
"type": "ref",
56
-
"ref": "#pullRequestTriggerData"
57
-
},
58
-
"manual": {
59
-
"type": "ref",
60
-
"ref": "#manualTriggerData"
61
-
}
62
-
}
63
-
},
64
-
"triggerRepo": {
65
-
"type": "object",
66
-
"required": [
67
-
"knot",
68
-
"did",
69
-
"repo",
70
-
"defaultBranch"
71
-
],
72
-
"properties": {
73
-
"knot": {
74
-
"type": "string"
75
-
},
76
-
"did": {
77
-
"type": "string",
78
-
"format": "did"
79
-
},
80
-
"repo": {
81
-
"type": "string"
82
-
},
83
-
"defaultBranch": {
84
-
"type": "string"
85
-
}
86
-
}
87
-
},
88
-
"pushTriggerData": {
89
-
"type": "object",
90
-
"required": [
91
-
"ref",
92
-
"newSha",
93
-
"oldSha"
94
-
],
95
-
"properties": {
96
-
"ref": {
97
-
"type": "string"
98
-
},
99
-
"newSha": {
100
-
"type": "string",
101
-
"minLength": 40,
102
-
"maxLength": 40
103
-
},
104
-
"oldSha": {
105
-
"type": "string",
106
-
"minLength": 40,
107
-
"maxLength": 40
108
-
}
109
-
}
110
-
},
111
-
"pullRequestTriggerData": {
112
-
"type": "object",
113
-
"required": [
114
-
"sourceBranch",
115
-
"targetBranch",
116
-
"sourceSha",
117
-
"action"
118
-
],
119
-
"properties": {
120
-
"sourceBranch": {
121
-
"type": "string"
122
-
},
123
-
"targetBranch": {
124
-
"type": "string"
125
-
},
126
-
"sourceSha": {
127
-
"type": "string",
128
-
"minLength": 40,
129
-
"maxLength": 40
130
-
},
131
-
"action": {
132
-
"type": "string"
133
-
}
134
-
}
135
-
},
136
-
"manualTriggerData": {
137
-
"type": "object",
138
-
"properties": {
139
-
"inputs": {
140
-
"type": "array",
141
-
"items": {
142
-
"type": "ref",
143
-
"ref": "#pair"
144
-
}
145
-
}
146
-
}
147
-
},
148
-
"workflow": {
149
-
"type": "object",
150
-
"required": [
151
-
"name",
152
-
"dependencies",
153
-
"steps",
154
-
"environment",
155
-
"clone"
156
-
],
157
-
"properties": {
158
-
"name": {
159
-
"type": "string"
160
-
},
161
-
"dependencies": {
162
-
"type": "array",
163
-
"items": {
164
-
"type": "ref",
165
-
"ref": "#dependency"
166
-
}
167
-
},
168
-
"steps": {
169
-
"type": "array",
170
-
"items": {
171
-
"type": "ref",
172
-
"ref": "#step"
173
-
}
174
-
},
175
-
"environment": {
176
-
"type": "array",
177
-
"items": {
178
-
"type": "ref",
179
-
"ref": "#pair"
180
-
}
181
-
},
182
-
"clone": {
183
-
"type": "ref",
184
-
"ref": "#cloneOpts"
185
-
}
186
-
}
187
-
},
188
-
"dependency": {
189
-
"type": "object",
190
-
"required": [
191
-
"registry",
192
-
"packages"
193
-
],
194
-
"properties": {
195
-
"registry": {
196
-
"type": "string"
197
-
},
198
-
"packages": {
199
-
"type": "array",
200
-
"items": {
201
-
"type": "string"
202
-
}
203
-
}
204
-
}
205
-
},
206
-
"cloneOpts": {
207
-
"type": "object",
208
-
"required": [
209
-
"skip",
210
-
"depth",
211
-
"submodules"
212
-
],
213
-
"properties": {
214
-
"skip": {
215
-
"type": "boolean"
216
-
},
217
-
"depth": {
218
-
"type": "integer"
219
-
},
220
-
"submodules": {
221
-
"type": "boolean"
222
-
}
223
-
}
224
-
},
225
-
"step": {
226
-
"type": "object",
227
-
"required": [
228
-
"name",
229
-
"command"
230
-
],
231
-
"properties": {
232
-
"name": {
233
-
"type": "string"
234
-
},
235
-
"command": {
236
-
"type": "string"
237
-
},
238
-
"environment": {
239
-
"type": "array",
240
-
"items": {
241
-
"type": "ref",
242
-
"ref": "#pair"
243
-
}
244
-
}
245
-
}
246
-
},
247
-
"pair": {
248
-
"type": "object",
249
-
"required": [
250
-
"key",
251
-
"value"
252
-
],
253
-
"properties": {
254
-
"key": {
255
-
"type": "string"
256
-
},
257
-
"value": {
258
-
"type": "string"
259
-
}
260
-
}
261
-
}
262
-
}
263
-
}
-31
lexicons/removeSecret.json
-31
lexicons/removeSecret.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.removeSecret",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Remove a CI secret",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"key"
15
-
],
16
-
"properties": {
17
-
"repo": {
18
-
"type": "string",
19
-
"format": "at-uri"
20
-
},
21
-
"key": {
22
-
"type": "string",
23
-
"maxLength": 50,
24
-
"minLength": 1
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
30
-
}
31
-
}
+37
lexicons/repo/addSecret.json
+37
lexicons/repo/addSecret.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.addSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Add a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key",
15
+
"value"
16
+
],
17
+
"properties": {
18
+
"repo": {
19
+
"type": "string",
20
+
"format": "at-uri"
21
+
},
22
+
"key": {
23
+
"type": "string",
24
+
"maxLength": 50,
25
+
"minLength": 1
26
+
},
27
+
"value": {
28
+
"type": "string",
29
+
"maxLength": 200,
30
+
"minLength": 1
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
}
+52
lexicons/repo/artifact.json
+52
lexicons/repo/artifact.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.artifact",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"repo",
15
+
"tag",
16
+
"createdAt",
17
+
"artifact"
18
+
],
19
+
"properties": {
20
+
"name": {
21
+
"type": "string",
22
+
"description": "name of the artifact"
23
+
},
24
+
"repo": {
25
+
"type": "string",
26
+
"format": "at-uri",
27
+
"description": "repo that this artifact is being uploaded to"
28
+
},
29
+
"tag": {
30
+
"type": "bytes",
31
+
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
32
+
"minLength": 20,
33
+
"maxLength": 20
34
+
},
35
+
"createdAt": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"description": "time of creation of this artifact"
39
+
},
40
+
"artifact": {
41
+
"type": "blob",
42
+
"description": "the artifact",
43
+
"accept": [
44
+
"*/*"
45
+
],
46
+
"maxSize": 52428800
47
+
}
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
+36
lexicons/repo/collaborator.json
+36
lexicons/repo/collaborator.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.collaborator",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"repo",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "did"
21
+
},
22
+
"repo": {
23
+
"type": "string",
24
+
"description": "repo to add this user to",
25
+
"format": "at-uri"
26
+
},
27
+
"createdAt": {
28
+
"type": "string",
29
+
"format": "datetime"
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
+29
lexicons/repo/defaultBranch.json
+29
lexicons/repo/defaultBranch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.setDefaultBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Set the default branch for a repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"defaultBranch"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"defaultBranch": {
22
+
"type": "string"
23
+
}
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
+67
lexicons/repo/listSecrets.json
+67
lexicons/repo/listSecrets.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.listSecrets",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": [
10
+
"repo"
11
+
],
12
+
"properties": {
13
+
"repo": {
14
+
"type": "string",
15
+
"format": "at-uri"
16
+
}
17
+
}
18
+
},
19
+
"output": {
20
+
"encoding": "application/json",
21
+
"schema": {
22
+
"type": "object",
23
+
"required": [
24
+
"secrets"
25
+
],
26
+
"properties": {
27
+
"secrets": {
28
+
"type": "array",
29
+
"items": {
30
+
"type": "ref",
31
+
"ref": "#secret"
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
},
38
+
"secret": {
39
+
"type": "object",
40
+
"required": [
41
+
"repo",
42
+
"key",
43
+
"createdAt",
44
+
"createdBy"
45
+
],
46
+
"properties": {
47
+
"repo": {
48
+
"type": "string",
49
+
"format": "at-uri"
50
+
},
51
+
"key": {
52
+
"type": "string",
53
+
"maxLength": 50,
54
+
"minLength": 1
55
+
},
56
+
"createdAt": {
57
+
"type": "string",
58
+
"format": "datetime"
59
+
},
60
+
"createdBy": {
61
+
"type": "string",
62
+
"format": "did"
63
+
}
64
+
}
65
+
}
66
+
}
67
+
}
+31
lexicons/repo/removeSecret.json
+31
lexicons/repo/removeSecret.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.removeSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Remove a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"key": {
22
+
"type": "string",
23
+
"maxLength": 50,
24
+
"minLength": 1
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
+54
lexicons/repo/repo.json
+54
lexicons/repo/repo.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"knot",
15
+
"owner",
16
+
"createdAt"
17
+
],
18
+
"properties": {
19
+
"name": {
20
+
"type": "string",
21
+
"description": "name of the repo"
22
+
},
23
+
"owner": {
24
+
"type": "string",
25
+
"format": "did"
26
+
},
27
+
"knot": {
28
+
"type": "string",
29
+
"description": "knot where the repo was created"
30
+
},
31
+
"spindle": {
32
+
"type": "string",
33
+
"description": "CI runner to send jobs to and receive results from"
34
+
},
35
+
"description": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"minGraphemes": 1,
39
+
"maxGraphemes": 140
40
+
},
41
+
"source": {
42
+
"type": "string",
43
+
"format": "uri",
44
+
"description": "source of the repo"
45
+
},
46
+
"createdAt": {
47
+
"type": "string",
48
+
"format": "datetime"
49
+
}
50
+
}
51
+
}
52
+
}
53
+
}
54
+
}
-54
lexicons/repo.json
-54
lexicons/repo.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"name",
14
-
"knot",
15
-
"owner",
16
-
"createdAt"
17
-
],
18
-
"properties": {
19
-
"name": {
20
-
"type": "string",
21
-
"description": "name of the repo"
22
-
},
23
-
"owner": {
24
-
"type": "string",
25
-
"format": "did"
26
-
},
27
-
"knot": {
28
-
"type": "string",
29
-
"description": "knot where the repo was created"
30
-
},
31
-
"spindle": {
32
-
"type": "string",
33
-
"description": "CI runner to send jobs to and receive results from"
34
-
},
35
-
"description": {
36
-
"type": "string",
37
-
"format": "datetime",
38
-
"minGraphemes": 1,
39
-
"maxGraphemes": 140
40
-
},
41
-
"source": {
42
-
"type": "string",
43
-
"format": "uri",
44
-
"description": "source of the repo"
45
-
},
46
-
"createdAt": {
47
-
"type": "string",
48
-
"format": "datetime"
49
-
}
50
-
}
51
-
}
52
-
}
53
-
}
54
-
}
+25
lexicons/spindle/spindle.json
+25
lexicons/spindle/spindle.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.spindle",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"createdAt"
14
+
],
15
+
"properties": {
16
+
"createdAt": {
17
+
"type": "string",
18
+
"format": "datetime"
19
+
}
20
+
}
21
+
}
22
+
}
23
+
}
24
+
}
25
+
-25
lexicons/spindle.json
-25
lexicons/spindle.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.spindle",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "any",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"createdAt"
14
-
],
15
-
"properties": {
16
-
"createdAt": {
17
-
"type": "string",
18
-
"format": "datetime"
19
-
}
20
-
}
21
-
}
22
-
}
23
-
}
24
-
}
25
-
+40
lexicons/string/string.json
+40
lexicons/string/string.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.string",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"filename",
14
+
"description",
15
+
"createdAt",
16
+
"contents"
17
+
],
18
+
"properties": {
19
+
"filename": {
20
+
"type": "string",
21
+
"maxGraphemes": 140,
22
+
"minGraphemes": 1
23
+
},
24
+
"description": {
25
+
"type": "string",
26
+
"maxGraphemes": 280
27
+
},
28
+
"createdAt": {
29
+
"type": "string",
30
+
"format": "datetime"
31
+
},
32
+
"contents": {
33
+
"type": "string",
34
+
"minGraphemes": 1
35
+
}
36
+
}
37
+
}
38
+
}
39
+
}
40
+
}
+85
-19
nix/gomod2nix.toml
+85
-19
nix/gomod2nix.toml
···
11
11
version = "v0.6.2"
12
12
hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU="
13
13
[mod."github.com/ProtonMail/go-crypto"]
14
-
version = "v1.2.0"
15
-
hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo="
14
+
version = "v1.3.0"
15
+
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
+
[mod."github.com/alecthomas/assert/v2"]
17
+
version = "v2.11.0"
18
+
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
16
19
[mod."github.com/alecthomas/chroma/v2"]
17
20
version = "v2.19.0"
18
21
hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM="
19
22
replaced = "github.com/oppiliappan/chroma/v2"
23
+
[mod."github.com/alecthomas/repr"]
24
+
version = "v0.4.0"
25
+
hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU="
20
26
[mod."github.com/anmitsu/go-shlex"]
21
27
version = "v0.0.0-20200514113438-38f4b401e2be"
22
28
hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54="
···
51
57
[mod."github.com/casbin/govaluate"]
52
58
version = "v1.3.0"
53
59
hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA="
60
+
[mod."github.com/cenkalti/backoff/v4"]
61
+
version = "v4.3.0"
62
+
hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8="
54
63
[mod."github.com/cespare/xxhash/v2"]
55
64
version = "v2.3.0"
56
65
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
57
66
[mod."github.com/cloudflare/circl"]
58
-
version = "v1.6.0"
59
-
hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0="
67
+
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
+
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
69
+
[mod."github.com/cloudflare/cloudflare-go"]
70
+
version = "v0.115.0"
71
+
hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw="
60
72
[mod."github.com/containerd/errdefs"]
61
73
version = "v1.0.0"
62
74
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
105
117
[mod."github.com/felixge/httpsnoop"]
106
118
version = "v1.0.4"
107
119
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
120
+
[mod."github.com/fsnotify/fsnotify"]
121
+
version = "v1.6.0"
122
+
hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0="
108
123
[mod."github.com/gliderlabs/ssh"]
109
124
version = "v0.3.8"
110
125
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
···
127
142
version = "v5.17.0"
128
143
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
129
144
replaced = "github.com/oppiliappan/go-git/v5"
145
+
[mod."github.com/go-jose/go-jose/v3"]
146
+
version = "v3.0.4"
147
+
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
130
148
[mod."github.com/go-logr/logr"]
131
149
version = "v1.4.3"
132
150
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
···
136
154
[mod."github.com/go-redis/cache/v9"]
137
155
version = "v9.0.0"
138
156
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
157
+
[mod."github.com/go-test/deep"]
158
+
version = "v1.1.1"
159
+
hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8="
139
160
[mod."github.com/goccy/go-json"]
140
161
version = "v0.10.5"
141
162
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
···
148
169
[mod."github.com/golang/groupcache"]
149
170
version = "v0.0.0-20241129210726-2c02b8208cf8"
150
171
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
172
+
[mod."github.com/golang/mock"]
173
+
version = "v1.6.0"
174
+
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
175
+
[mod."github.com/google/go-querystring"]
176
+
version = "v1.1.0"
177
+
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
151
178
[mod."github.com/google/uuid"]
152
179
version = "v1.6.0"
153
180
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
···
161
188
version = "v1.4.0"
162
189
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
163
190
[mod."github.com/gorilla/websocket"]
164
-
version = "v1.5.3"
165
-
hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0="
191
+
version = "v1.5.4-0.20250319132907-e064f32e3674"
192
+
hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to="
193
+
[mod."github.com/hashicorp/errwrap"]
194
+
version = "v1.1.0"
195
+
hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw="
166
196
[mod."github.com/hashicorp/go-cleanhttp"]
167
197
version = "v0.5.2"
168
198
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
199
+
[mod."github.com/hashicorp/go-multierror"]
200
+
version = "v1.1.1"
201
+
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
169
202
[mod."github.com/hashicorp/go-retryablehttp"]
170
203
version = "v0.7.8"
171
204
hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80="
205
+
[mod."github.com/hashicorp/go-secure-stdlib/parseutil"]
206
+
version = "v0.2.0"
207
+
hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8="
208
+
[mod."github.com/hashicorp/go-secure-stdlib/strutil"]
209
+
version = "v0.1.2"
210
+
hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A="
211
+
[mod."github.com/hashicorp/go-sockaddr"]
212
+
version = "v1.0.7"
213
+
hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs="
172
214
[mod."github.com/hashicorp/golang-lru"]
173
215
version = "v1.0.2"
174
216
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
175
217
[mod."github.com/hashicorp/golang-lru/v2"]
176
218
version = "v2.0.7"
177
219
hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g="
220
+
[mod."github.com/hashicorp/hcl"]
221
+
version = "v1.0.1-vault-7"
222
+
hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM="
223
+
[mod."github.com/hexops/gotextdiff"]
224
+
version = "v1.0.3"
225
+
hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0="
178
226
[mod."github.com/hiddeco/sshsig"]
179
227
version = "v0.2.0"
180
228
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
···
256
304
[mod."github.com/minio/sha256-simd"]
257
305
version = "v1.0.1"
258
306
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
307
+
[mod."github.com/mitchellh/mapstructure"]
308
+
version = "v1.5.0"
309
+
hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE="
259
310
[mod."github.com/moby/docker-image-spec"]
260
311
version = "v1.3.1"
261
312
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
···
289
340
[mod."github.com/munnerz/goautoneg"]
290
341
version = "v0.0.0-20191010083416-a7dc8b61c822"
291
342
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
343
+
[mod."github.com/onsi/gomega"]
344
+
version = "v1.37.0"
345
+
hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o="
346
+
[mod."github.com/openbao/openbao/api/v2"]
347
+
version = "v2.3.0"
348
+
hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM="
292
349
[mod."github.com/opencontainers/go-digest"]
293
350
version = "v1.0.0"
294
351
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
···
296
353
version = "v1.1.1"
297
354
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
298
355
[mod."github.com/opentracing/opentracing-go"]
299
-
version = "v1.2.0"
300
-
hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM="
356
+
version = "v1.2.1-0.20220228012449-10b1cf09e00b"
357
+
hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw="
301
358
[mod."github.com/pjbgf/sha1cd"]
302
359
version = "v0.3.2"
303
360
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
···
326
383
version = "v0.16.1"
327
384
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
328
385
[mod."github.com/redis/go-redis/v9"]
329
-
version = "v9.3.0"
330
-
hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w="
386
+
version = "v9.7.3"
387
+
hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo="
331
388
[mod."github.com/resend/resend-go/v2"]
332
389
version = "v2.15.0"
333
390
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
391
+
[mod."github.com/ryanuber/go-glob"]
392
+
version = "v1.0.0"
393
+
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
334
394
[mod."github.com/segmentio/asm"]
335
395
version = "v1.2.0"
336
396
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
···
380
440
[mod."go.opentelemetry.io/otel"]
381
441
version = "v1.37.0"
382
442
hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo="
443
+
[mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"]
444
+
version = "v1.33.0"
445
+
hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I="
383
446
[mod."go.opentelemetry.io/otel/metric"]
384
447
version = "v1.37.0"
385
448
hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg="
···
405
468
version = "v0.0.0-20250620022241-b7579e27df2b"
406
469
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
407
470
[mod."golang.org/x/net"]
408
-
version = "v0.41.0"
409
-
hash = "sha256-6/pi8rNmGvBFzkJQXkXkMfL1Bjydhg3BgAMYDyQ/Uvg="
471
+
version = "v0.42.0"
472
+
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
410
473
[mod."golang.org/x/sync"]
411
-
version = "v0.15.0"
412
-
hash = "sha256-Jf4ehm8H8YAWY6mM151RI5CbG7JcOFtmN0AZx4bE3UE="
474
+
version = "v0.16.0"
475
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
413
476
[mod."golang.org/x/sys"]
414
477
version = "v0.34.0"
415
478
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
479
+
[mod."golang.org/x/text"]
480
+
version = "v0.27.0"
481
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
416
482
[mod."golang.org/x/time"]
417
483
version = "v0.12.0"
418
484
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
420
486
version = "v0.0.0-20240903120638-7835f813f4da"
421
487
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
422
488
[mod."google.golang.org/genproto/googleapis/api"]
423
-
version = "v0.0.0-20250519155744-55703ea1f237"
424
-
hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ="
489
+
version = "v0.0.0-20250603155806-513f23925822"
490
+
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="
425
491
[mod."google.golang.org/genproto/googleapis/rpc"]
426
-
version = "v0.0.0-20250519155744-55703ea1f237"
492
+
version = "v0.0.0-20250603155806-513f23925822"
427
493
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
428
494
[mod."google.golang.org/grpc"]
429
-
version = "v1.72.1"
430
-
hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs="
495
+
version = "v1.73.0"
496
+
hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c="
431
497
[mod."google.golang.org/protobuf"]
432
498
version = "v1.36.6"
433
499
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
+32
-1
nix/modules/knot.nix
+32
-1
nix/modules/knot.nix
···
58
58
};
59
59
};
60
60
61
+
motd = mkOption {
62
+
type = types.nullOr types.str;
63
+
default = null;
64
+
description = ''
65
+
Message of the day
66
+
67
+
The contents are shown as-is; eg. you will want to add a newline if
68
+
setting a non-empty message since the knot won't do this for you.
69
+
'';
70
+
};
71
+
72
+
motdFile = mkOption {
73
+
type = types.nullOr types.path;
74
+
default = null;
75
+
description = ''
76
+
File containing message of the day
77
+
78
+
The contents are shown as-is; eg. you will want to add a newline if
79
+
setting a non-empty message since the knot won't do this for you.
80
+
'';
81
+
};
82
+
61
83
server = {
62
84
listenAddr = mkOption {
63
85
type = types.str;
···
104
126
cfg.package
105
127
];
106
128
107
-
system.activationScripts.gitConfig = ''
129
+
system.activationScripts.gitConfig = let
130
+
setMotd =
131
+
if cfg.motdFile != null && cfg.motd != null
132
+
then throw "motdFile and motd cannot be both set"
133
+
else ''
134
+
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
135
+
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
136
+
'';
137
+
in ''
108
138
mkdir -p "${cfg.repo.scanPath}"
109
139
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
110
140
···
116
146
[receive]
117
147
advertisePushOptions = true
118
148
EOF
149
+
${setMotd}
119
150
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
120
151
'';
121
152
+22
nix/modules/spindle.nix
+22
nix/modules/spindle.nix
···
54
54
example = "did:plc:qfpnj4og54vl56wngdriaxug";
55
55
description = "DID of owner (required)";
56
56
};
57
+
58
+
secrets = {
59
+
provider = mkOption {
60
+
type = types.str;
61
+
default = "sqlite";
62
+
description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'.";
63
+
};
64
+
65
+
openbao = {
66
+
proxyAddr = mkOption {
67
+
type = types.str;
68
+
default = "http://127.0.0.1:8200";
69
+
};
70
+
mount = mkOption {
71
+
type = types.str;
72
+
default = "spindle";
73
+
};
74
+
};
75
+
};
57
76
};
58
77
59
78
pipelines = {
···
89
108
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
90
109
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
91
110
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
111
+
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
112
+
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
113
+
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
92
114
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
93
115
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
94
116
];
+23
nix/pkgs/appview-static-files.nix
+23
nix/pkgs/appview-static-files.nix
···
1
+
{
2
+
runCommandLocal,
3
+
htmx-src,
4
+
htmx-ws-src,
5
+
lucide-src,
6
+
inter-fonts-src,
7
+
ibm-plex-mono-src,
8
+
sqlite-lib,
9
+
tailwindcss,
10
+
src,
11
+
}:
12
+
runCommandLocal "appview-static-files" {} ''
13
+
mkdir -p $out/{fonts,icons} && cd $out
14
+
cp -f ${htmx-src} htmx.min.js
15
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
16
+
cp -rf ${lucide-src}/*.svg icons/
17
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
18
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
19
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
20
+
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
21
+
# for whatever reason (produces broken css), so we are doing this instead
22
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
23
+
''
+5
-17
nix/pkgs/appview.nix
+5
-17
nix/pkgs/appview.nix
···
1
1
{
2
2
buildGoApplication,
3
3
modules,
4
-
htmx-src,
5
-
htmx-ws-src,
6
-
lucide-src,
7
-
inter-fonts-src,
8
-
ibm-plex-mono-src,
9
-
tailwindcss,
4
+
appview-static-files,
10
5
sqlite-lib,
11
-
gitignoreSource,
6
+
src,
12
7
}:
13
8
buildGoApplication {
14
9
pname = "appview";
15
10
version = "0.1.0";
16
-
src = gitignoreSource ../..;
17
-
inherit modules;
11
+
inherit src modules;
18
12
19
13
postUnpack = ''
20
14
pushd source
21
-
mkdir -p appview/pages/static/{fonts,icons}
22
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
23
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
24
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
25
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
26
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
27
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
28
-
${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
15
+
mkdir -p appview/pages/static
16
+
cp -frv ${appview-static-files}/* appview/pages/static
29
17
popd
30
18
'';
31
19
+2
-3
nix/pkgs/genjwks.nix
+2
-3
nix/pkgs/genjwks.nix
···
1
1
{
2
-
gitignoreSource,
2
+
src,
3
3
buildGoApplication,
4
4
modules,
5
5
}:
6
6
buildGoApplication {
7
7
pname = "genjwks";
8
8
version = "0.1.0";
9
-
src = gitignoreSource ../..;
10
-
inherit modules;
9
+
inherit src modules;
11
10
subPackages = ["cmd/genjwks"];
12
11
doCheck = false;
13
12
CGO_ENABLED = 0;
+2
-3
nix/pkgs/knot-unwrapped.nix
+2
-3
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/lexgen.nix
+1
-1
nix/pkgs/lexgen.nix
+2
-3
nix/pkgs/spindle.nix
+2
-3
nix/pkgs/spindle.nix
+81
-62
nix/vm.nix
+81
-62
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
+
system,
3
4
self,
4
-
}:
5
-
nixpkgs.lib.nixosSystem {
6
-
system = "x86_64-linux";
7
-
modules = [
8
-
self.nixosModules.knot
9
-
self.nixosModules.spindle
10
-
({
11
-
config,
12
-
pkgs,
13
-
...
14
-
}: {
15
-
virtualisation = {
16
-
memorySize = 2048;
17
-
diskSize = 10 * 1024;
18
-
cores = 2;
19
-
forwardPorts = [
20
-
# ssh
21
-
{
22
-
from = "host";
23
-
host.port = 2222;
24
-
guest.port = 22;
25
-
}
26
-
# knot
27
-
{
28
-
from = "host";
29
-
host.port = 6000;
30
-
guest.port = 6000;
31
-
}
32
-
# spindle
33
-
{
34
-
from = "host";
35
-
host.port = 6555;
36
-
guest.port = 6555;
37
-
}
5
+
}: let
6
+
envVar = name: let
7
+
var = builtins.getEnv name;
8
+
in
9
+
if var == ""
10
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
11
+
else var;
12
+
in
13
+
nixpkgs.lib.nixosSystem {
14
+
inherit system;
15
+
modules = [
16
+
self.nixosModules.knot
17
+
self.nixosModules.spindle
18
+
({
19
+
config,
20
+
pkgs,
21
+
...
22
+
}: {
23
+
nixos-shell = {
24
+
inheritPath = false;
25
+
mounts = {
26
+
mountHome = false;
27
+
mountNixProfile = false;
28
+
};
29
+
};
30
+
virtualisation = {
31
+
memorySize = 2048;
32
+
diskSize = 10 * 1024;
33
+
cores = 2;
34
+
forwardPorts = [
35
+
# ssh
36
+
{
37
+
from = "host";
38
+
host.port = 2222;
39
+
guest.port = 22;
40
+
}
41
+
# knot
42
+
{
43
+
from = "host";
44
+
host.port = 6000;
45
+
guest.port = 6000;
46
+
}
47
+
# spindle
48
+
{
49
+
from = "host";
50
+
host.port = 6555;
51
+
guest.port = 6555;
52
+
}
53
+
];
54
+
};
55
+
services.getty.autologinUser = "root";
56
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
57
+
systemd.tmpfiles.rules = let
58
+
u = config.services.tangled-knot.gitUser;
59
+
g = config.services.tangled-knot.gitUser;
60
+
in [
61
+
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
62
+
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}"
38
63
];
39
-
};
40
-
services.getty.autologinUser = "root";
41
-
environment.systemPackages = with pkgs; [curl vim git];
42
-
systemd.tmpfiles.rules = let
43
-
u = config.services.tangled-knot.gitUser;
44
-
g = config.services.tangled-knot.gitUser;
45
-
in [
46
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
47
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
48
-
];
49
-
services.tangled-knot = {
50
-
enable = true;
51
-
server = {
52
-
secretFile = "/var/lib/knot/secret";
53
-
hostname = "localhost:6000";
54
-
listenAddr = "0.0.0.0:6000";
64
+
services.tangled-knot = {
65
+
enable = true;
66
+
motd = "Welcome to the development knot!\n";
67
+
server = {
68
+
secretFile = "/var/lib/knot/secret";
69
+
hostname = "localhost:6000";
70
+
listenAddr = "0.0.0.0:6000";
71
+
};
55
72
};
56
-
};
57
-
services.tangled-spindle = {
58
-
enable = true;
59
-
server = {
60
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
61
-
hostname = "localhost:6555";
62
-
listenAddr = "0.0.0.0:6555";
63
-
dev = true;
73
+
services.tangled-spindle = {
74
+
enable = true;
75
+
server = {
76
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
77
+
hostname = "localhost:6555";
78
+
listenAddr = "0.0.0.0:6555";
79
+
dev = true;
80
+
secrets = {
81
+
provider = "sqlite";
82
+
};
83
+
};
64
84
};
65
-
};
66
-
})
67
-
];
68
-
}
85
+
})
86
+
];
87
+
}
+2
-4
spindle/config/config.go
+2
-4
spindle/config/config.go
···
28
28
}
29
29
30
30
type OpenBaoConfig struct {
31
-
Addr string `env:"ADDR"`
32
-
RoleID string `env:"ROLE_ID"`
33
-
SecretID string `env:"SECRET_ID"`
34
-
Mount string `env:"MOUNT, default=spindle"`
31
+
ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"`
32
+
Mount string `env:"MOUNT, default=spindle"`
35
33
}
36
34
37
35
type Pipelines struct {
+15
spindle/db/db.go
+15
spindle/db/db.go
···
45
45
unique(owner, name)
46
46
);
47
47
48
+
create table if not exists spindle_members (
49
+
-- identifiers for the record
50
+
id integer primary key autoincrement,
51
+
did text not null,
52
+
rkey text not null,
53
+
54
+
-- data
55
+
instance text not null,
56
+
subject text not null,
57
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
58
+
59
+
-- constraints
60
+
unique (did, instance, subject)
61
+
);
62
+
48
63
-- status event for a single workflow
49
64
create table if not exists events (
50
65
rkey text not null,
+59
spindle/db/member.go
+59
spindle/db/member.go
···
1
+
package db
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type SpindleMember struct {
10
+
Id int
11
+
Did syntax.DID // owner of the record
12
+
Rkey string // rkey of the record
13
+
Instance string
14
+
Subject syntax.DID // the member being added
15
+
Created time.Time
16
+
}
17
+
18
+
func AddSpindleMember(db *DB, member SpindleMember) error {
19
+
_, err := db.Exec(
20
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
21
+
member.Did,
22
+
member.Rkey,
23
+
member.Instance,
24
+
member.Subject,
25
+
)
26
+
return err
27
+
}
28
+
29
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
30
+
_, err := db.Exec(
31
+
"delete from spindle_members where did = ? and rkey = ?",
32
+
owner_did,
33
+
rkey,
34
+
)
35
+
return err
36
+
}
37
+
38
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
39
+
query :=
40
+
`select id, did, rkey, instance, subject, created
41
+
from spindle_members
42
+
where did = ? and rkey = ?`
43
+
44
+
var member SpindleMember
45
+
var createdAt string
46
+
err := db.QueryRow(query, did, rkey).Scan(
47
+
&member.Id,
48
+
&member.Did,
49
+
&member.Rkey,
50
+
&member.Instance,
51
+
&member.Subject,
52
+
&createdAt,
53
+
)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &member, nil
59
+
}
+15
-3
spindle/engine/engine.go
+15
-3
spindle/engine/engine.go
···
25
25
"tangled.sh/tangled.sh/core/spindle/config"
26
26
"tangled.sh/tangled.sh/core/spindle/db"
27
27
"tangled.sh/tangled.sh/core/spindle/models"
28
+
"tangled.sh/tangled.sh/core/spindle/oidc"
28
29
"tangled.sh/tangled.sh/core/spindle/secrets"
29
30
)
30
31
···
41
42
n *notifier.Notifier
42
43
cfg *config.Config
43
44
vault secrets.Manager
45
+
oidc oidc.OidcTokenGenerator
44
46
45
47
cleanupMu sync.Mutex
46
48
cleanup map[string][]cleanupFunc
47
49
}
48
50
49
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
51
+
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager, oidc *oidc.OidcTokenGenerator) (*Engine, error) {
50
52
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
51
53
if err != nil {
52
54
return nil, err
···
61
63
n: n,
62
64
cfg: cfg,
63
65
vault: vault,
66
+
oidc: *oidc,
64
67
}
65
68
66
69
e.cleanup = make(map[string][]cleanupFunc)
···
124
127
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
125
128
defer cancel()
126
129
127
-
err = e.StartSteps(ctx, wid, w, allSecrets)
130
+
err = e.StartSteps(ctx, wid, w, allSecrets, pipeline, pipelineId)
128
131
if err != nil {
129
132
if errors.Is(err, ErrTimedOut) {
130
133
dbErr := e.db.StatusTimeout(wid, e.n)
···
202
205
// ONLY marks pipeline as failed if container's exit code is non-zero.
203
206
// All other errors are bubbled up.
204
207
// Fixed version of the step execution logic
205
-
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
208
+
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret, pipeline *models.Pipeline, pipelineId models.PipelineId) error {
206
209
workflowEnvs := ConstructEnvs(w.Environment)
207
210
for _, s := range secrets {
208
211
workflowEnvs.AddEnv(s.Key, s.Value)
···
221
224
}
222
225
envs.AddEnv("HOME", workspaceDir)
223
226
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
227
+
228
+
for _, t := range step.OidcTokens {
229
+
token, err := e.oidc.CreateToken(t, pipelineId, pipeline.RepoOwner, pipeline.RepoName)
230
+
if err != nil {
231
+
e.l.Error("failed to get OIDC token", "error", err, "token", t.Name)
232
+
return fmt.Errorf("getting OIDC token: %w", err)
233
+
}
234
+
envs.AddEnv(t.Name, token)
235
+
}
224
236
225
237
hostConfig := hostConfig(wid)
226
238
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
+161
-7
spindle/ingester.go
+161
-7
spindle/ingester.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
-
"path/filepath"
8
+
"time"
8
9
9
10
"tangled.sh/tangled.sh/core/api/tangled"
10
11
"tangled.sh/tangled.sh/core/eventconsumer"
12
+
"tangled.sh/tangled.sh/core/idresolver"
11
13
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/db"
12
15
16
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
+
"github.com/bluesky-social/indigo/atproto/identity"
18
+
"github.com/bluesky-social/indigo/atproto/syntax"
19
+
"github.com/bluesky-social/indigo/xrpc"
13
20
"github.com/bluesky-social/jetstream/pkg/models"
21
+
securejoin "github.com/cyphar/filepath-securejoin"
14
22
)
15
23
16
24
type Ingester func(ctx context.Context, e *models.Event) error
···
35
43
s.ingestMember(ctx, e)
36
44
case tangled.RepoNSID:
37
45
s.ingestRepo(ctx, e)
46
+
case tangled.RepoCollaboratorNSID:
47
+
s.ingestCollaborator(ctx, e)
38
48
}
39
49
40
50
return err
···
42
52
}
43
53
44
54
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
55
+
var err error
45
56
did := e.Did
46
-
var err error
57
+
rkey := e.Commit.RKey
47
58
48
59
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
49
60
···
58
69
}
59
70
60
71
domain := s.cfg.Server.Hostname
61
-
if s.cfg.Server.Dev {
62
-
domain = s.cfg.Server.ListenAddr
63
-
}
64
72
recordInstance := record.Instance
65
73
66
74
if recordInstance != domain {
···
74
82
return fmt.Errorf("failed to enforce permissions: %w", err)
75
83
}
76
84
85
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
86
+
Did: syntax.DID(did),
87
+
Rkey: rkey,
88
+
Instance: recordInstance,
89
+
Subject: syntax.DID(record.Subject),
90
+
Created: time.Now(),
91
+
}); err != nil {
92
+
l.Error("failed to add member", "error", err)
93
+
return fmt.Errorf("failed to add member: %w", err)
94
+
}
95
+
77
96
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
78
97
l.Error("failed to add member", "error", err)
79
98
return fmt.Errorf("failed to add member: %w", err)
···
88
107
89
108
return nil
90
109
110
+
case models.CommitOperationDelete:
111
+
record, err := db.GetSpindleMember(s.db, did, rkey)
112
+
if err != nil {
113
+
l.Error("failed to find member", "error", err)
114
+
return fmt.Errorf("failed to find member: %w", err)
115
+
}
116
+
117
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
118
+
l.Error("failed to remove member", "error", err)
119
+
return fmt.Errorf("failed to remove member: %w", err)
120
+
}
121
+
122
+
if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil {
123
+
l.Error("failed to add member", "error", err)
124
+
return fmt.Errorf("failed to add member: %w", err)
125
+
}
126
+
l.Info("added member from firehose", "member", record.Subject)
127
+
128
+
if err := s.db.RemoveDid(record.Subject.String()); err != nil {
129
+
l.Error("failed to add did", "error", err)
130
+
return fmt.Errorf("failed to add did: %w", err)
131
+
}
132
+
s.jc.RemoveDid(record.Subject.String())
133
+
91
134
}
92
135
return nil
93
136
}
94
137
95
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
138
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
96
139
var err error
140
+
did := e.Did
141
+
resolver := idresolver.DefaultResolver()
97
142
98
143
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
99
144
···
129
174
return fmt.Errorf("failed to add repo: %w", err)
130
175
}
131
176
177
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
178
+
if err != nil {
179
+
return err
180
+
}
181
+
132
182
// add repo to rbac
133
-
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil {
183
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
134
184
l.Error("failed to add repo to enforcer", "error", err)
135
185
return fmt.Errorf("failed to add repo: %w", err)
136
186
}
137
187
188
+
// add collaborators to rbac
189
+
owner, err := resolver.ResolveIdent(ctx, did)
190
+
if err != nil || owner.Handle.IsInvalidHandle() {
191
+
return err
192
+
}
193
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
194
+
return err
195
+
}
196
+
138
197
// add this knot to the event consumer
139
198
src := eventconsumer.NewKnotSource(record.Knot)
140
199
s.ks.AddSource(context.Background(), src)
···
144
203
}
145
204
return nil
146
205
}
206
+
207
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
208
+
var err error
209
+
210
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
211
+
212
+
l.Info("ingesting collaborator record")
213
+
214
+
switch e.Commit.Operation {
215
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
216
+
raw := e.Commit.Record
217
+
record := tangled.RepoCollaborator{}
218
+
err = json.Unmarshal(raw, &record)
219
+
if err != nil {
220
+
l.Error("invalid record", "error", err)
221
+
return err
222
+
}
223
+
224
+
resolver := idresolver.DefaultResolver()
225
+
226
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
227
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
228
+
return err
229
+
}
230
+
231
+
repoAt, err := syntax.ParseATURI(record.Repo)
232
+
if err != nil {
233
+
l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo)
234
+
return nil
235
+
}
236
+
237
+
// TODO: get rid of this entirely
238
+
// resolve this aturi to extract the repo record
239
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
240
+
if err != nil || owner.Handle.IsInvalidHandle() {
241
+
return fmt.Errorf("failed to resolve handle: %w", err)
242
+
}
243
+
244
+
xrpcc := xrpc.Client{
245
+
Host: owner.PDSEndpoint(),
246
+
}
247
+
248
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
249
+
if err != nil {
250
+
return err
251
+
}
252
+
253
+
repo := resp.Value.Val.(*tangled.Repo)
254
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
255
+
256
+
// check perms for this user
257
+
if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
258
+
return fmt.Errorf("insufficient permissions: %w", err)
259
+
}
260
+
261
+
// add collaborator to rbac
262
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
263
+
l.Error("failed to add repo to enforcer", "error", err)
264
+
return fmt.Errorf("failed to add repo: %w", err)
265
+
}
266
+
267
+
return nil
268
+
}
269
+
return nil
270
+
}
271
+
272
+
func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error {
273
+
l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators")
274
+
275
+
l.Info("fetching and adding existing collaborators")
276
+
277
+
xrpcc := xrpc.Client{
278
+
Host: owner.PDSEndpoint(),
279
+
}
280
+
281
+
resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false)
282
+
if err != nil {
283
+
return err
284
+
}
285
+
286
+
var errs error
287
+
for _, r := range resp.Records {
288
+
if r == nil {
289
+
continue
290
+
}
291
+
record := r.Value.Val.(*tangled.RepoCollaborator)
292
+
293
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
294
+
l.Error("failed to add repo to enforcer", "error", err)
295
+
errors.Join(errs, fmt.Errorf("failed to add repo: %w", err))
296
+
}
297
+
}
298
+
299
+
return errs
300
+
}
+14
spindle/models/pipeline.go
+14
spindle/models/pipeline.go
···
18
18
Name string
19
19
Environment map[string]string
20
20
Kind StepKind
21
+
OidcTokens []OidcToken
21
22
}
22
23
23
24
type StepKind int
···
28
29
// steps defined by the user in the original pipeline
29
30
StepKindUser
30
31
)
32
+
33
+
type OidcToken struct {
34
+
Name string
35
+
Aud *string
36
+
}
31
37
32
38
type Workflow struct {
33
39
Steps []Step
···
60
66
sstep.Name = tstep.Name
61
67
sstep.Kind = StepKindUser
62
68
swf.Steps = append(swf.Steps, sstep)
69
+
70
+
sstep.OidcTokens = make([]OidcToken, 0, len(tstep.Oidcs_tokens))
71
+
for _, ttoken := range tstep.Oidcs_tokens {
72
+
sstep.OidcTokens = append(sstep.OidcTokens, OidcToken{
73
+
Name: ttoken.Name,
74
+
Aud: ttoken.Aud,
75
+
})
76
+
}
63
77
}
64
78
swf.Name = twf.Name
65
79
swf.Environment = workflowEnvToMap(twf.Environment)
+320
spindle/oidc/oidc.go
+320
spindle/oidc/oidc.go
···
1
+
package oidc
2
+
3
+
import (
4
+
"crypto/ecdsa"
5
+
"crypto/elliptic"
6
+
"crypto/rand"
7
+
"encoding/json"
8
+
"fmt"
9
+
"log/slog"
10
+
"net/http"
11
+
"reflect"
12
+
"time"
13
+
14
+
"github.com/lestrrat-go/jwx/v2/jwa"
15
+
"github.com/lestrrat-go/jwx/v2/jwk"
16
+
"github.com/lestrrat-go/jwx/v2/jwt"
17
+
"tangled.sh/tangled.sh/core/spindle/models"
18
+
)
19
+
20
+
const JWKSPath = "/.well-known/jwks.json"
21
+
const WebFingerPath = "/.well-known/webfinger"
22
+
23
+
// OidcKeyPair represents an OIDC key pair with both private and public keys
24
+
type OidcKeyPair struct {
25
+
privateKey *ecdsa.PrivateKey
26
+
publicKey *ecdsa.PublicKey
27
+
keyID string
28
+
jwkKey jwk.Key
29
+
}
30
+
31
+
// OidcTokenGenerator handles OIDC token generation and key management with rotation
32
+
type OidcTokenGenerator struct {
33
+
currentKeyPair OidcKeyPair
34
+
nextKeyPair *OidcKeyPair
35
+
l *slog.Logger
36
+
issuer string
37
+
claimsSupported []string
38
+
}
39
+
40
+
// NewOidcTokenGenerator creates a new OIDC token generator with in-memory key management
41
+
func NewOidcTokenGenerator(issuer string) (*OidcTokenGenerator, error) {
42
+
// Create new keys
43
+
currentKeyPair, err := NewOidcKeyPair()
44
+
if err != nil {
45
+
return nil, fmt.Errorf("failed to generate initial current key pair: %w", err)
46
+
}
47
+
48
+
// Use reflection to get claim field names from OidcClaims
49
+
var claimsSupported []string
50
+
claimsType := reflect.TypeOf(OidcClaims{})
51
+
for i := 0; i < claimsType.NumField(); i++ {
52
+
tag := claimsType.Field(i).Tag.Get("json")
53
+
if tag != "" {
54
+
claimsSupported = append(claimsSupported, tag)
55
+
}
56
+
}
57
+
58
+
return &OidcTokenGenerator{
59
+
issuer: issuer,
60
+
currentKeyPair: *currentKeyPair,
61
+
claimsSupported: claimsSupported,
62
+
}, nil
63
+
}
64
+
65
+
// NewOidcKeyPair generates a new ECDSA key pair for OIDC token signing
66
+
func NewOidcKeyPair() (*OidcKeyPair, error) {
67
+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
68
+
if err != nil {
69
+
return nil, fmt.Errorf("failed to generate ECDSA key: %w", err)
70
+
}
71
+
72
+
keyID := fmt.Sprintf("spindle-%d", time.Now().Unix())
73
+
74
+
// Create JWK from the private key
75
+
jwkKey, err := jwk.FromRaw(privKey)
76
+
if err != nil {
77
+
return nil, fmt.Errorf("failed to create JWK from private key: %w", err)
78
+
}
79
+
80
+
// Set the key ID
81
+
if err := jwkKey.Set(jwk.KeyIDKey, keyID); err != nil {
82
+
return nil, fmt.Errorf("failed to set key ID: %w", err)
83
+
}
84
+
85
+
// Set algorithm
86
+
if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil {
87
+
return nil, fmt.Errorf("failed to set algorithm: %w", err)
88
+
}
89
+
90
+
// Set usage
91
+
if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil {
92
+
return nil, fmt.Errorf("failed to set key usage: %w", err)
93
+
}
94
+
95
+
return &OidcKeyPair{
96
+
privateKey: privKey,
97
+
publicKey: &privKey.PublicKey,
98
+
keyID: keyID,
99
+
jwkKey: jwkKey,
100
+
}, nil
101
+
}
102
+
103
+
func (k *OidcKeyPair) GetKeyID() string {
104
+
return k.keyID
105
+
}
106
+
107
+
// RotateKeys performs key rotation: generates new next key, moves next to current
108
+
func (g *OidcTokenGenerator) RotateKeys() error {
109
+
// Generate a new key pair for the next key
110
+
newNextKeyPair, err := NewOidcKeyPair()
111
+
if err != nil {
112
+
return fmt.Errorf("failed to generate new next key pair: %w", err)
113
+
}
114
+
115
+
// Perform rotation: next becomes current, new key becomes next
116
+
g.currentKeyPair = *g.nextKeyPair
117
+
g.nextKeyPair = newNextKeyPair
118
+
119
+
return nil
120
+
}
121
+
122
+
// OidcClaims represents the claims in an OIDC token
123
+
type OidcClaims struct {
124
+
// Standard JWT claims
125
+
Issuer string `json:"iss"`
126
+
Subject string `json:"sub"`
127
+
Audience string `json:"aud"`
128
+
ExpiresAt int64 `json:"exp"`
129
+
NotBefore int64 `json:"nbf"`
130
+
IssuedAt int64 `json:"iat"`
131
+
JWTID string `json:"jti"`
132
+
}
133
+
134
+
// CreateToken creates a signed JWT token for the given OidcToken and pipeline context
135
+
func (g *OidcTokenGenerator) CreateToken(
136
+
oidcToken models.OidcToken,
137
+
pipelineId models.PipelineId,
138
+
repoOwner, repoName string,
139
+
) (string, error) {
140
+
now := time.Now()
141
+
exp := now.Add(5 * time.Minute)
142
+
143
+
// Determine audience - use the provided audience or default to issuer
144
+
audience := g.issuer
145
+
if oidcToken.Aud != nil && *oidcToken.Aud != "" {
146
+
audience = *oidcToken.Aud
147
+
}
148
+
149
+
pipelineUri := pipelineId.AtUri()
150
+
151
+
// Create claims
152
+
claims := OidcClaims{
153
+
Issuer: g.issuer,
154
+
// Hardcode the did as did:web of the issuer. At some point knots will have their own DIDs which will be used here
155
+
Subject: pipelineUri.String(),
156
+
Audience: audience,
157
+
ExpiresAt: exp.Unix(),
158
+
NotBefore: now.Unix(),
159
+
IssuedAt: now.Unix(),
160
+
// Repo owner, name, and id should be global unique but we add timestamp to ensure uniqueness
161
+
JWTID: fmt.Sprintf("%s/%s-%s-%d", repoOwner, repoName, pipelineUri.RecordKey(), now.Unix()),
162
+
}
163
+
164
+
// Create JWT token
165
+
token := jwt.New()
166
+
167
+
// Set all claims
168
+
if err := token.Set(jwt.IssuerKey, claims.Issuer); err != nil {
169
+
return "", fmt.Errorf("failed to set issuer: %w", err)
170
+
}
171
+
if err := token.Set(jwt.SubjectKey, claims.Subject); err != nil {
172
+
return "", fmt.Errorf("failed to set subject: %w", err)
173
+
}
174
+
if err := token.Set(jwt.AudienceKey, claims.Audience); err != nil {
175
+
return "", fmt.Errorf("failed to set audience: %w", err)
176
+
}
177
+
if err := token.Set(jwt.ExpirationKey, claims.ExpiresAt); err != nil {
178
+
return "", fmt.Errorf("failed to set expiration: %w", err)
179
+
}
180
+
if err := token.Set(jwt.NotBeforeKey, claims.NotBefore); err != nil {
181
+
return "", fmt.Errorf("failed to set not before: %w", err)
182
+
}
183
+
if err := token.Set(jwt.IssuedAtKey, claims.IssuedAt); err != nil {
184
+
return "", fmt.Errorf("failed to set issued at: %w", err)
185
+
}
186
+
if err := token.Set(jwt.JwtIDKey, claims.JWTID); err != nil {
187
+
return "", fmt.Errorf("failed to set JWT ID: %w", err)
188
+
}
189
+
190
+
// Sign the token with the current key
191
+
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, g.currentKeyPair.jwkKey))
192
+
if err != nil {
193
+
return "", fmt.Errorf("failed to sign token: %w", err)
194
+
}
195
+
196
+
return string(signedToken), nil
197
+
}
198
+
199
+
// JWKSHandler serves the JWKS endpoint (const JWKSPath)
200
+
func (g *OidcTokenGenerator) JWKSHandler(w http.ResponseWriter, r *http.Request) {
201
+
pubJWK, err := jwk.PublicKeyOf(g.currentKeyPair.jwkKey)
202
+
if err != nil {
203
+
http.Error(w, fmt.Sprintf("failed to extract current public key from JWK: %v", err), http.StatusInternalServerError)
204
+
return
205
+
}
206
+
var keys []jwk.Key
207
+
keys = append(keys, pubJWK)
208
+
209
+
// Add next key if available
210
+
if g.nextKeyPair != nil {
211
+
pubJWK, err := jwk.PublicKeyOf(g.nextKeyPair.jwkKey)
212
+
if err != nil {
213
+
http.Error(w, fmt.Sprintf("failed to extract next public key from JWK: %v", err), http.StatusInternalServerError)
214
+
return
215
+
}
216
+
keys = append(keys, pubJWK)
217
+
}
218
+
219
+
if len(keys) == 0 {
220
+
http.Error(w, "no keys available for JWKS", http.StatusInternalServerError)
221
+
return
222
+
}
223
+
224
+
jwks := map[string]interface{}{
225
+
"keys": keys,
226
+
}
227
+
228
+
w.Header().Set("Content-Type", "application/json")
229
+
if err := json.NewEncoder(w).Encode(jwks); err != nil {
230
+
http.Error(w, fmt.Sprintf("failed to encode JWKS: %v", err), http.StatusInternalServerError)
231
+
}
232
+
}
233
+
234
+
// DiscoveryHandler serves the OIDC discovery endpoint (/.well-known/openid-configuration)
235
+
func (g *OidcTokenGenerator) DiscoveryHandler(w http.ResponseWriter, r *http.Request) {
236
+
237
+
responseTypesSupported := []string{
238
+
"id_token",
239
+
}
240
+
241
+
subjectTypesSupported := []string{
242
+
"public",
243
+
}
244
+
245
+
idTokenSigningAlgValuesSupported := []string{
246
+
jwa.RS256.String(),
247
+
}
248
+
249
+
scopesSupported := []string{
250
+
"openid",
251
+
}
252
+
253
+
discovery := map[string]interface{}{
254
+
"issuer": g.issuer,
255
+
"jwks_uri": fmt.Sprintf("%s%s", g.issuer, JWKSPath),
256
+
"claims_supported": g.claimsSupported,
257
+
"response_types_supported": responseTypesSupported,
258
+
"subject_types_supported": subjectTypesSupported,
259
+
"id_token_signing_alg_values_supported": idTokenSigningAlgValuesSupported,
260
+
"scopes_supported": scopesSupported,
261
+
}
262
+
w.Header().Set("Content-Type", "application/json")
263
+
if err := json.NewEncoder(w).Encode(discovery); err != nil {
264
+
http.Error(w, fmt.Sprintf("failed to encode discovery document: %v", err), http.StatusInternalServerError)
265
+
}
266
+
}
267
+
268
+
// WebFingerResponse represents the WebFinger response format
269
+
type WebFingerResponse struct {
270
+
Subject string `json:"subject"`
271
+
Links []WebFingerLink `json:"links"`
272
+
}
273
+
274
+
// WebFingerLink represents a link in the WebFinger response
275
+
type WebFingerLink struct {
276
+
Rel string `json:"rel"`
277
+
Href string `json:"href"`
278
+
}
279
+
280
+
// WebFingerHandler serves the WebFinger endpoint for issuer discovery (/.well-known/webfinger)
281
+
// This implements OpenID Connect Discovery 1.0 Section 2 - OpenID Provider Issuer Discovery
282
+
func (g *OidcTokenGenerator) WebFingerHandler(w http.ResponseWriter, r *http.Request) {
283
+
// Parse query parameters
284
+
resource := r.URL.Query().Get("resource")
285
+
rel := r.URL.Query().Get("rel")
286
+
287
+
// Check if this is an OpenID Connect issuer discovery request
288
+
expectedRel := "http://openid.net/specs/connect/1.0/issuer"
289
+
if rel != "" && rel != expectedRel {
290
+
http.Error(w, "unsupported rel parameter", http.StatusBadRequest)
291
+
return
292
+
}
293
+
294
+
if resource == "" {
295
+
http.Error(w, "resource parameter is required", http.StatusBadRequest)
296
+
return
297
+
}
298
+
299
+
// Check if the resource matches the issuer
300
+
if resource != g.issuer {
301
+
http.Error(w, "issuer not found", http.StatusNotFound)
302
+
return
303
+
}
304
+
305
+
// Create the WebFinger response
306
+
response := WebFingerResponse{
307
+
Subject: resource,
308
+
Links: []WebFingerLink{
309
+
{
310
+
Rel: expectedRel,
311
+
Href: g.issuer,
312
+
},
313
+
},
314
+
}
315
+
316
+
w.Header().Set("Content-Type", "application/jrd+json")
317
+
if err := json.NewEncoder(w).Encode(response); err != nil {
318
+
http.Error(w, fmt.Sprintf("failed to encode WebFinger response: %v", err), http.StatusInternalServerError)
319
+
}
320
+
}
+56
-150
spindle/secrets/openbao.go
+56
-150
spindle/secrets/openbao.go
···
6
6
"log/slog"
7
7
"path"
8
8
"strings"
9
-
"sync"
10
9
"time"
11
10
12
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
16
15
type OpenBaoManager struct {
17
16
client *vault.Client
18
17
mountPath string
19
-
roleID string
20
-
secretID string
21
-
stopCh chan struct{}
22
-
tokenMu sync.RWMutex
23
18
logger *slog.Logger
24
19
}
25
20
···
31
26
}
32
27
}
33
28
34
-
func NewOpenBaoManager(address, roleID, secretID string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
35
-
if address == "" {
36
-
return nil, fmt.Errorf("address cannot be empty")
37
-
}
38
-
if roleID == "" {
39
-
return nil, fmt.Errorf("role_id cannot be empty")
40
-
}
41
-
if secretID == "" {
42
-
return nil, fmt.Errorf("secret_id cannot be empty")
29
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
30
+
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
31
+
// The proxy handles all authentication automatically via Auto-Auth
32
+
func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
33
+
if proxyAddress == "" {
34
+
return nil, fmt.Errorf("proxy address cannot be empty")
43
35
}
44
36
45
37
config := vault.DefaultConfig()
46
-
config.Address = address
38
+
config.Address = proxyAddress
47
39
48
40
client, err := vault.NewClient(config)
49
41
if err != nil {
50
42
return nil, fmt.Errorf("failed to create openbao client: %w", err)
51
43
}
52
44
53
-
// Authenticate using AppRole
54
-
err = authenticateAppRole(client, roleID, secretID)
55
-
if err != nil {
56
-
return nil, fmt.Errorf("failed to authenticate with AppRole: %w", err)
57
-
}
58
-
59
45
manager := &OpenBaoManager{
60
46
client: client,
61
47
mountPath: "spindle", // default KV v2 mount path
62
-
roleID: roleID,
63
-
secretID: secretID,
64
-
stopCh: make(chan struct{}),
65
48
logger: logger,
66
49
}
67
50
···
69
52
opt(manager)
70
53
}
71
54
72
-
go manager.tokenRenewalLoop()
73
-
74
-
return manager, nil
75
-
}
76
-
77
-
// authenticateAppRole authenticates the client using AppRole method
78
-
func authenticateAppRole(client *vault.Client, roleID, secretID string) error {
79
-
authData := map[string]interface{}{
80
-
"role_id": roleID,
81
-
"secret_id": secretID,
82
-
}
83
-
84
-
resp, err := client.Logical().Write("auth/approle/login", authData)
85
-
if err != nil {
86
-
return fmt.Errorf("failed to login with AppRole: %w", err)
87
-
}
88
-
89
-
if resp == nil || resp.Auth == nil {
90
-
return fmt.Errorf("no auth info returned from AppRole login")
91
-
}
92
-
93
-
client.SetToken(resp.Auth.ClientToken)
94
-
return nil
95
-
}
96
-
97
-
// stop stops the token renewal goroutine
98
-
func (v *OpenBaoManager) Stop() {
99
-
close(v.stopCh)
100
-
}
101
-
102
-
// tokenRenewalLoop runs in a background goroutine to automatically renew or re-authenticate tokens
103
-
func (v *OpenBaoManager) tokenRenewalLoop() {
104
-
ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds
105
-
defer ticker.Stop()
106
-
107
-
for {
108
-
select {
109
-
case <-v.stopCh:
110
-
return
111
-
case <-ticker.C:
112
-
ctx := context.Background()
113
-
if err := v.ensureValidToken(ctx); err != nil {
114
-
v.logger.Error("openbao token renewal failed", "error", err)
115
-
}
116
-
}
117
-
}
118
-
}
119
-
120
-
// ensureValidToken checks if the current token is valid and renews or re-authenticates if needed
121
-
func (v *OpenBaoManager) ensureValidToken(ctx context.Context) error {
122
-
v.tokenMu.Lock()
123
-
defer v.tokenMu.Unlock()
124
-
125
-
// check current token info
126
-
tokenInfo, err := v.client.Auth().Token().LookupSelf()
127
-
if err != nil {
128
-
// token is invalid, need to re-authenticate
129
-
v.logger.Warn("token lookup failed, re-authenticating", "error", err)
130
-
return v.reAuthenticate()
131
-
}
132
-
133
-
if tokenInfo == nil || tokenInfo.Data == nil {
134
-
return v.reAuthenticate()
135
-
}
136
-
137
-
// check TTL
138
-
ttlRaw, ok := tokenInfo.Data["ttl"]
139
-
if !ok {
140
-
return v.reAuthenticate()
141
-
}
142
-
143
-
var ttl int64
144
-
switch t := ttlRaw.(type) {
145
-
case int64:
146
-
ttl = t
147
-
case float64:
148
-
ttl = int64(t)
149
-
case int:
150
-
ttl = int64(t)
151
-
default:
152
-
return v.reAuthenticate()
153
-
}
154
-
155
-
// if TTL is less than 5 minutes, try to renew
156
-
if ttl < 300 {
157
-
v.logger.Info("token ttl low, attempting renewal", "ttl_seconds", ttl)
158
-
159
-
renewResp, err := v.client.Auth().Token().RenewSelf(3600) // 1h
160
-
if err != nil {
161
-
v.logger.Warn("token renewal failed, re-authenticating", "error", err)
162
-
return v.reAuthenticate()
163
-
}
164
-
165
-
if renewResp == nil || renewResp.Auth == nil {
166
-
v.logger.Warn("token renewal returned no auth info, re-authenticating")
167
-
return v.reAuthenticate()
168
-
}
169
-
170
-
v.logger.Info("token renewed successfully", "new_ttl_seconds", renewResp.Auth.LeaseDuration)
55
+
if err := manager.testConnection(); err != nil {
56
+
return nil, fmt.Errorf("failed to connect to bao proxy: %w", err)
171
57
}
172
58
173
-
return nil
59
+
logger.Info("successfully connected to bao proxy", "address", proxyAddress)
60
+
return manager, nil
174
61
}
175
62
176
-
// reAuthenticate performs a fresh authentication using AppRole
177
-
func (v *OpenBaoManager) reAuthenticate() error {
178
-
v.logger.Info("re-authenticating with approle")
63
+
// testConnection verifies that we can connect to the proxy
64
+
func (v *OpenBaoManager) testConnection() error {
65
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
66
+
defer cancel()
179
67
180
-
err := authenticateAppRole(v.client, v.roleID, v.secretID)
68
+
// try token self-lookup as a quick way to verify proxy works
69
+
// and is authenticated
70
+
_, err := v.client.Auth().Token().LookupSelfWithContext(ctx)
181
71
if err != nil {
182
-
return fmt.Errorf("re-authentication failed: %w", err)
72
+
return fmt.Errorf("proxy connection test failed: %w", err)
183
73
}
184
74
185
-
v.logger.Info("re-authentication successful")
186
75
return nil
187
76
}
188
77
189
78
func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
190
-
v.tokenMu.RLock()
191
-
defer v.tokenMu.RUnlock()
192
79
if err := ValidateKey(secret.Key); err != nil {
193
80
return err
194
81
}
195
82
196
83
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
197
-
198
-
fmt.Println(v.mountPath, secretPath)
84
+
v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath)
199
85
86
+
// Check if secret already exists
200
87
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
201
88
if err == nil && existing != nil {
89
+
v.logger.Debug("secret already exists", "path", secretPath)
202
90
return ErrKeyAlreadyPresent
203
91
}
204
92
···
210
98
"created_by": secret.CreatedBy.String(),
211
99
}
212
100
213
-
_, err = v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
101
+
v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath)
102
+
resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
214
103
if err != nil {
104
+
v.logger.Error("failed to write secret", "path", secretPath, "error", err)
215
105
return fmt.Errorf("failed to store secret in openbao: %w", err)
216
106
}
217
107
108
+
v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime)
109
+
110
+
v.logger.Debug("verifying secret was written", "path", secretPath)
111
+
readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
112
+
if err != nil {
113
+
v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err)
114
+
return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err)
115
+
}
116
+
117
+
if readBack == nil || readBack.Data == nil {
118
+
v.logger.Error("secret verification returned empty data", "path", secretPath)
119
+
return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath)
120
+
}
121
+
122
+
v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version)
218
123
return nil
219
124
}
220
125
221
126
func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
222
-
v.tokenMu.RLock()
223
-
defer v.tokenMu.RUnlock()
224
127
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
225
128
129
+
// check if secret exists
226
130
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
227
131
if err != nil || existing == nil {
228
132
return ErrKeyNotFound
229
133
}
230
134
231
-
err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath)
135
+
err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath)
232
136
if err != nil {
233
137
return fmt.Errorf("failed to delete secret from openbao: %w", err)
234
138
}
235
139
140
+
v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key)
236
141
return nil
237
142
}
238
143
239
144
func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
240
-
v.tokenMu.RLock()
241
-
defer v.tokenMu.RUnlock()
242
145
repoPath := v.buildRepoPath(repo)
243
146
244
-
secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
147
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
245
148
if err != nil {
246
149
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
247
150
return []LockedSecret{}, nil
···
266
169
continue
267
170
}
268
171
269
-
secretPath := path.Join(repoPath, key)
172
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
270
173
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
271
174
if err != nil {
272
-
continue // Skip secrets we can't read
175
+
v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err)
176
+
continue
273
177
}
274
178
275
179
if secretData == nil || secretData.Data == nil {
···
308
212
secrets = append(secrets, secret)
309
213
}
310
214
215
+
v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets))
311
216
return secrets, nil
312
217
}
313
218
314
219
func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
315
-
v.tokenMu.RLock()
316
-
defer v.tokenMu.RUnlock()
317
220
repoPath := v.buildRepoPath(repo)
318
221
319
-
secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
222
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
320
223
if err != nil {
321
224
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
322
225
return []UnlockedSecret{}, nil
···
341
244
continue
342
245
}
343
246
344
-
secretPath := path.Join(repoPath, key)
247
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
345
248
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
346
249
if err != nil {
250
+
v.logger.Warn("failed to read secret", "path", secretPath, "error", err)
347
251
continue
348
252
}
349
253
···
355
259
356
260
valueStr, ok := data["value"].(string)
357
261
if !ok {
358
-
continue // skip secrets without values
262
+
v.logger.Warn("secret missing value", "path", secretPath)
263
+
continue
359
264
}
360
265
361
266
createdAtStr, ok := data["created_at"].(string)
···
389
294
secrets = append(secrets, secret)
390
295
}
391
296
297
+
v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets))
392
298
return secrets, nil
393
299
}
394
300
395
-
// buildRepoPath creates an OpenBao path for a repository
301
+
// buildRepoPath creates a safe path for a repository
396
302
func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string {
397
303
// convert DidSlashRepo to a safe path by replacing special characters
398
304
repoPath := strings.ReplaceAll(string(repo), "/", "_")
···
401
307
return fmt.Sprintf("repos/%s", repoPath)
402
308
}
403
309
404
-
// buildSecretPath creates an OpenBao path for a specific secret
310
+
// buildSecretPath creates a path for a specific secret
405
311
func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string {
406
312
return path.Join(v.buildRepoPath(repo), key)
407
313
}
+59
-84
spindle/secrets/openbao_test.go
+59
-84
spindle/secrets/openbao_test.go
···
16
16
secrets map[string]UnlockedSecret // key: repo_key format
17
17
shouldError bool
18
18
errorToReturn error
19
-
stopped bool
20
19
}
21
20
22
21
func NewMockOpenBaoManager() *MockOpenBaoManager {
···
31
30
func (m *MockOpenBaoManager) ClearError() {
32
31
m.shouldError = false
33
32
m.errorToReturn = nil
34
-
}
35
-
36
-
func (m *MockOpenBaoManager) Stop() {
37
-
m.stopped = true
38
-
}
39
-
40
-
func (m *MockOpenBaoManager) IsStopped() bool {
41
-
return m.stopped
42
33
}
43
34
44
35
func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string {
···
118
109
}
119
110
}
120
111
112
+
// Test MockOpenBaoManager interface compliance
113
+
func TestMockOpenBaoManagerInterface(t *testing.T) {
114
+
var _ Manager = (*MockOpenBaoManager)(nil)
115
+
}
116
+
121
117
func TestOpenBaoManagerInterface(t *testing.T) {
122
118
var _ Manager = (*OpenBaoManager)(nil)
123
119
}
···
125
121
func TestNewOpenBaoManager(t *testing.T) {
126
122
tests := []struct {
127
123
name string
128
-
address string
129
-
roleID string
130
-
secretID string
124
+
proxyAddr string
131
125
opts []OpenBaoManagerOpt
132
126
expectError bool
133
127
errorContains string
134
128
}{
135
129
{
136
-
name: "empty address",
137
-
address: "",
138
-
roleID: "test-role-id",
139
-
secretID: "test-secret-id",
130
+
name: "empty proxy address",
131
+
proxyAddr: "",
140
132
opts: nil,
141
133
expectError: true,
142
-
errorContains: "address cannot be empty",
134
+
errorContains: "proxy address cannot be empty",
143
135
},
144
136
{
145
-
name: "empty role_id",
146
-
address: "http://localhost:8200",
147
-
roleID: "",
148
-
secretID: "test-secret-id",
137
+
name: "valid proxy address",
138
+
proxyAddr: "http://localhost:8200",
149
139
opts: nil,
150
-
expectError: true,
151
-
errorContains: "role_id cannot be empty",
140
+
expectError: true, // Will fail because no real proxy is running
141
+
errorContains: "failed to connect to bao proxy",
152
142
},
153
143
{
154
-
name: "empty secret_id",
155
-
address: "http://localhost:8200",
156
-
roleID: "test-role-id",
157
-
secretID: "",
158
-
opts: nil,
159
-
expectError: true,
160
-
errorContains: "secret_id cannot be empty",
144
+
name: "with mount path option",
145
+
proxyAddr: "http://localhost:8200",
146
+
opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")},
147
+
expectError: true, // Will fail because no real proxy is running
148
+
errorContains: "failed to connect to bao proxy",
161
149
},
162
150
}
163
151
164
152
for _, tt := range tests {
165
153
t.Run(tt.name, func(t *testing.T) {
166
154
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
167
-
manager, err := NewOpenBaoManager(tt.address, tt.roleID, tt.secretID, logger, tt.opts...)
155
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
168
156
169
157
if tt.expectError {
170
158
assert.Error(t, err)
171
159
assert.Nil(t, manager)
172
160
assert.Contains(t, err.Error(), tt.errorContains)
173
161
} else {
174
-
// For valid configurations, we expect an error during authentication
175
-
// since we're not connecting to a real OpenBao server
176
-
assert.Error(t, err)
177
-
assert.Nil(t, manager)
162
+
assert.NoError(t, err)
163
+
assert.NotNil(t, manager)
178
164
}
179
165
})
180
166
}
···
253
239
assert.Equal(t, "custom-mount", manager.mountPath)
254
240
}
255
241
256
-
func TestOpenBaoManager_Stop(t *testing.T) {
257
-
// Create a manager with minimal setup
258
-
manager := &OpenBaoManager{
259
-
mountPath: "test",
260
-
stopCh: make(chan struct{}),
261
-
}
262
-
263
-
// Verify the manager implements Stopper interface
264
-
var stopper Stopper = manager
265
-
assert.NotNil(t, stopper)
266
-
267
-
// Call Stop and verify it doesn't panic
268
-
assert.NotPanics(t, func() {
269
-
manager.Stop()
270
-
})
271
-
272
-
// Verify the channel was closed
273
-
select {
274
-
case <-manager.stopCh:
275
-
// Channel was closed as expected
276
-
default:
277
-
t.Error("Expected stop channel to be closed after Stop()")
278
-
}
279
-
}
280
-
281
-
func TestOpenBaoManager_StopperInterface(t *testing.T) {
282
-
manager := &OpenBaoManager{}
283
-
284
-
// Verify that OpenBaoManager implements the Stopper interface
285
-
_, ok := interface{}(manager).(Stopper)
286
-
assert.True(t, ok, "OpenBaoManager should implement Stopper interface")
287
-
}
288
-
289
-
// Test MockOpenBaoManager interface compliance
290
-
func TestMockOpenBaoManagerInterface(t *testing.T) {
291
-
var _ Manager = (*MockOpenBaoManager)(nil)
292
-
var _ Stopper = (*MockOpenBaoManager)(nil)
293
-
}
294
-
295
242
func TestMockOpenBaoManager_AddSecret(t *testing.T) {
296
243
tests := []struct {
297
244
name string
···
563
510
assert.NoError(t, err)
564
511
}
565
512
566
-
func TestMockOpenBaoManager_Stop(t *testing.T) {
567
-
mock := NewMockOpenBaoManager()
568
-
569
-
assert.False(t, mock.IsStopped())
570
-
571
-
mock.Stop()
572
-
573
-
assert.True(t, mock.IsStopped())
574
-
}
575
-
576
513
func TestMockOpenBaoManager_Integration(t *testing.T) {
577
514
tests := []struct {
578
515
name string
···
628
565
})
629
566
}
630
567
}
568
+
569
+
func TestOpenBaoManager_ProxyConfiguration(t *testing.T) {
570
+
tests := []struct {
571
+
name string
572
+
proxyAddr string
573
+
description string
574
+
}{
575
+
{
576
+
name: "default_localhost",
577
+
proxyAddr: "http://127.0.0.1:8200",
578
+
description: "Should connect to default localhost proxy",
579
+
},
580
+
{
581
+
name: "custom_host",
582
+
proxyAddr: "http://bao-proxy:8200",
583
+
description: "Should connect to custom proxy host",
584
+
},
585
+
{
586
+
name: "https_proxy",
587
+
proxyAddr: "https://127.0.0.1:8200",
588
+
description: "Should connect to HTTPS proxy",
589
+
},
590
+
}
591
+
592
+
for _, tt := range tests {
593
+
t.Run(tt.name, func(t *testing.T) {
594
+
t.Log("Testing scenario:", tt.description)
595
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
596
+
597
+
// All these will fail because no real proxy is running
598
+
// but we can test that the configuration is properly accepted
599
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
600
+
assert.Error(t, err) // Expected because no real proxy
601
+
assert.Nil(t, manager)
602
+
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
603
+
})
604
+
}
605
+
}
+13
-6
spindle/secrets/policy.hcl
+13
-6
spindle/secrets/policy.hcl
···
1
-
# KV v2 data operations
2
-
path "spindle/data/*" {
1
+
# Allow full access to the spindle KV mount
2
+
path "spindle/*" {
3
3
capabilities = ["create", "read", "update", "delete", "list"]
4
4
}
5
5
6
-
# KV v2 metadata operations (needed for listing)
6
+
path "spindle/data/*" {
7
+
capabilities = ["create", "read", "update", "delete"]
8
+
}
9
+
7
10
path "spindle/metadata/*" {
8
11
capabilities = ["list", "read", "delete"]
9
12
}
10
13
11
-
# Root path access (needed for mount-level operations)
12
-
path "spindle/*" {
13
-
capabilities = ["list"]
14
+
# Allow listing mounts (for connection testing)
15
+
path "sys/mounts" {
16
+
capabilities = ["read"]
14
17
}
15
18
19
+
# Allow token self-lookup (for health checks)
20
+
path "auth/token/lookup-self" {
21
+
capabilities = ["read"]
22
+
}
+31
-16
spindle/server.go
+31
-16
spindle/server.go
···
21
21
"tangled.sh/tangled.sh/core/spindle/db"
22
22
"tangled.sh/tangled.sh/core/spindle/engine"
23
23
"tangled.sh/tangled.sh/core/spindle/models"
24
+
"tangled.sh/tangled.sh/core/spindle/oidc"
24
25
"tangled.sh/tangled.sh/core/spindle/queue"
25
26
"tangled.sh/tangled.sh/core/spindle/secrets"
26
27
"tangled.sh/tangled.sh/core/spindle/xrpc"
···
71
72
var vault secrets.Manager
72
73
switch cfg.Server.Secrets.Provider {
73
74
case "openbao":
74
-
if cfg.Server.Secrets.OpenBao.Addr == "" {
75
-
return fmt.Errorf("openbao address is required when using openbao secrets provider")
76
-
}
77
-
if cfg.Server.Secrets.OpenBao.RoleID == "" {
78
-
return fmt.Errorf("openbao role_id is required when using openbao secrets provider")
79
-
}
80
-
if cfg.Server.Secrets.OpenBao.SecretID == "" {
81
-
return fmt.Errorf("openbao secret_id is required when using openbao secrets provider")
75
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
76
+
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
82
77
}
83
78
vault, err = secrets.NewOpenBaoManager(
84
-
cfg.Server.Secrets.OpenBao.Addr,
85
-
cfg.Server.Secrets.OpenBao.RoleID,
86
-
cfg.Server.Secrets.OpenBao.SecretID,
79
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
87
80
logger,
88
81
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
89
82
)
90
83
if err != nil {
91
84
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
92
85
}
93
-
logger.Info("using openbao secrets provider", "address", cfg.Server.Secrets.OpenBao.Addr, "mount", cfg.Server.Secrets.OpenBao.Mount)
86
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
94
87
case "sqlite", "":
95
88
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
96
89
if err != nil {
···
101
94
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
102
95
}
103
96
104
-
eng, err := engine.New(ctx, cfg, d, &n, vault)
97
+
oidc, err := oidc.NewOidcTokenGenerator(fmt.Sprintf("https://%s", cfg.Server.Hostname))
98
+
if err != nil {
99
+
return fmt.Errorf("failed to create OIDC token generator: %w", err)
100
+
}
101
+
102
+
eng, err := engine.New(ctx, cfg, d, &n, vault, oidc)
105
103
if err != nil {
106
104
return err
107
105
}
108
106
109
-
jq := queue.NewQueue(100, 2)
107
+
jq := queue.NewQueue(100, 5)
110
108
111
109
collections := []string{
112
110
tangled.SpindleMemberNSID,
113
111
tangled.RepoNSID,
112
+
tangled.RepoCollaboratorNSID,
114
113
}
115
114
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
116
115
if err != nil {
117
116
return fmt.Errorf("failed to setup jetstream client: %w", err)
118
117
}
119
118
jc.AddDid(cfg.Server.Owner)
119
+
120
+
// Check if the spindle knows about any Dids;
121
+
dids, err := d.GetAllDids()
122
+
if err != nil {
123
+
return fmt.Errorf("failed to get all dids: %w", err)
124
+
}
125
+
for _, d := range dids {
126
+
jc.AddDid(d)
127
+
}
120
128
121
129
resolver := idresolver.DefaultResolver()
122
130
···
186
194
}()
187
195
188
196
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
189
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
197
+
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router(oidc)))
190
198
191
199
return nil
192
200
}
193
201
194
-
func (s *Spindle) Router() http.Handler {
202
+
func (s *Spindle) Router(oidcg *oidc.OidcTokenGenerator) http.Handler {
195
203
mux := chi.NewRouter()
196
204
197
205
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
···
202
210
w.Write([]byte(s.cfg.Server.Owner))
203
211
})
204
212
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
213
+
mux.HandleFunc(oidc.JWKSPath, oidcg.JWKSHandler)
214
+
mux.HandleFunc("/.well-known/oidc-configuration", oidcg.DiscoveryHandler)
215
+
mux.HandleFunc(oidc.WebFingerPath, oidcg.WebFingerHandler)
205
216
206
217
mux.Mount("/xrpc", s.XrpcRouter())
207
218
return mux
···
238
249
239
250
if tpl.TriggerMetadata.Repo == nil {
240
251
return fmt.Errorf("no repo data found")
252
+
}
253
+
254
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
255
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
241
256
}
242
257
243
258
// filter by repos