forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+809 -258
api
appview
db
pages
templates
repo
strings
cmd
knotserver
lexicons
pipeline
nix
spindle
engine
models
oidc
+242 -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:
+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.
+2 -2
appview/db/db.go
··· 728 728 kind := rv.Kind() 729 729 730 730 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 731 - if kind == reflect.Slice || kind == reflect.Array { 731 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 732 732 if rv.Len() == 0 { 733 733 // always false 734 734 return "1 = 0" ··· 748 748 func (f filter) Arg() []any { 749 749 rv := reflect.ValueOf(f.arg) 750 750 kind := rv.Kind() 751 - if kind == reflect.Slice || kind == reflect.Array { 751 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 752 752 if rv.Len() == 0 { 753 753 return nil 754 754 }
+2 -2
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 `, ··· 556 556 return err 557 557 } 558 558 559 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 559 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 560 560 _, err := e.Exec( 561 561 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 562 562 return err
+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>
+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
+9 -4
appview/pages/templates/repo/settings/pipelines.html
··· 34 34 {{ else }} 35 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 36 <select 37 - id="spindle" 37 + id="spindle" 38 38 name="spindle" 39 - required 39 + required 40 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 - <option value="" disabled> 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 }} 42 44 Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 43 48 </option> 44 49 {{ range $.Spindles }} 45 50 <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> ··· 82 87 {{ end }} 83 88 84 89 {{ define "addSecretButton" }} 85 - <button 90 + <button 86 91 class="btn flex items-center gap-2" 87 92 popovertarget="add-secret-modal" 88 93 popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .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
··· 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>
+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">
+25 -15
appview/repo/repo.go
··· 657 657 } 658 658 659 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 660 661 client, err := rp.oauth.AuthorizedClient(r) 661 662 if err != nil { 662 663 fail("Failed to authorize. Try again later.", err) 663 664 return 664 665 } 665 666 666 - // ensure that this is a valid spindle for this user 667 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 668 - if err != nil { 669 - fail("Failed to find spindles. Try again later.", err) 670 - 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 + } 671 679 } 672 680 673 - if !slices.Contains(validSpindles, newSpindle) { 674 - fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 675 - return 681 + spindlePtr := &newSpindle 682 + if removingSpindle { 683 + spindlePtr = nil 676 684 } 677 685 678 686 // optimistic update 679 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 687 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 680 688 if err != nil { 681 689 fail("Failed to update spindle. Try again later.", err) 682 690 return ··· 699 707 Owner: user.Did, 700 708 CreatedAt: f.CreatedAt, 701 709 Description: &f.Description, 702 - Spindle: &newSpindle, 710 + Spindle: spindlePtr, 703 711 }, 704 712 }, 705 713 }) ··· 709 717 return 710 718 } 711 719 712 - // add this spindle to spindle stream 713 - rp.spindlestream.AddSource( 714 - context.Background(), 715 - eventconsumer.NewSpindleSource(newSpindle), 716 - ) 720 + if !removingSpindle { 721 + // add this spindle to spindle stream 722 + rp.spindlestream.AddSource( 723 + context.Background(), 724 + eventconsumer.NewSpindleSource(newSpindle), 725 + ) 726 + } 717 727 718 728 rp.pages.HxRefresh(w) 719 729 }
+5
appview/strings/strings.go
··· 99 99 w.WriteHeader(http.StatusInternalServerError) 100 100 return 101 101 } 102 + if len(strings) < 1 { 103 + l.Error("string not found") 104 + s.Pages.Error404(w) 105 + return 106 + } 102 107 if len(strings) != 1 { 103 108 l.Error("incorrect number of records returned", "len(strings)", len(strings)) 104 109 w.WriteHeader(http.StatusInternalServerError)
+1
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{},
+53 -18
flake.nix
··· 75 75 }; 76 76 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 77 77 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 78 - appview = self.callPackage ./nix/pkgs/appview.nix { 78 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 79 79 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 80 80 }; 81 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 81 82 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 82 83 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 83 84 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 93 94 staticPackages = mkPackageSet pkgs.pkgsStatic; 94 95 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 95 96 in { 96 - appview = packages.appview; 97 - lexgen = packages.lexgen; 98 - knot = packages.knot; 99 - knot-unwrapped = packages.knot-unwrapped; 100 - spindle = packages.spindle; 101 - genjwks = packages.genjwks; 102 - sqlite-lib = packages.sqlite-lib; 97 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 103 98 104 99 pkgsStatic-appview = staticPackages.appview; 105 100 pkgsStatic-knot = staticPackages.knot; ··· 121 116 stdenv = pkgs.pkgsStatic.stdenv; 122 117 }; 123 118 in { 124 - default = staticShell { 119 + default = pkgs.mkShell { 125 120 nativeBuildInputs = [ 126 121 pkgs.go 127 122 pkgs.air ··· 132 127 pkgs.tailwindcss 133 128 pkgs.nixos-shell 134 129 pkgs.redis 130 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 135 131 packages'.lexgen 136 132 ]; 137 133 shellHook = '' 138 - mkdir -p appview/pages/static/{fonts,icons} 139 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 140 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 141 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 142 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 143 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 144 - 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 145 137 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 146 138 ''; 147 139 env.CGO_ENABLED = 1; ··· 149 141 }); 150 142 apps = forAllSystems (system: let 151 143 pkgs = nixpkgsFor."${system}"; 144 + packages' = self.packages.${system}; 152 145 air-watcher = name: arg: 153 146 pkgs.writeShellScriptBin "run" 154 147 '' ··· 167 160 in { 168 161 watch-appview = { 169 162 type = "app"; 170 - 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 + ''); 171 168 }; 172 169 watch-knot = { 173 170 type = "app"; ··· 182 179 if pkgs.stdenv.hostPlatform.isAarch64 183 180 then "aarch64" 184 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 + }); 185 195 in { 186 196 type = "app"; 187 197 program = toString (pkgs.writeShellScript "vm" '' 188 - ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm-${system} 198 + ${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux 189 199 ''); 190 200 }; 191 201 gomod2nix = { ··· 193 203 program = toString (pkgs.writeShellScript "gomod2nix" '' 194 204 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 195 205 ''); 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; 196 231 }; 197 232 }); 198 233
+4
input.css
··· 70 70 details summary::-webkit-details-marker { 71 71 display: none; 72 72 } 73 + 74 + code { 75 + @apply font-mono p-1 rounded bg-gray-100 dark:bg-gray-700; 76 + } 73 77 } 74 78 75 79 @layer components {
+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()))
+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 {
+17
lexicons/pipeline/pipeline.json
··· 241 241 "type": "ref", 242 242 "ref": "#pair" 243 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 + } 244 261 } 245 262 } 246 263 },
+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 + ''
+3 -14
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 6 src, 12 7 }: ··· 17 12 18 13 postUnpack = '' 19 14 pushd source 20 - mkdir -p appview/pages/static/{fonts,icons} 21 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 22 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 23 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 24 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 25 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 26 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 27 - ${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 28 17 popd 29 18 ''; 30 19
+8 -1
nix/vm.nix
··· 20 20 pkgs, 21 21 ... 22 22 }: { 23 + nixos-shell = { 24 + inheritPath = false; 25 + mounts = { 26 + mountHome = false; 27 + mountNixProfile = false; 28 + }; 29 + }; 23 30 virtualisation = { 24 31 memorySize = 2048; 25 32 diskSize = 10 * 1024; ··· 46 53 ]; 47 54 }; 48 55 services.getty.autologinUser = "root"; 49 - environment.systemPackages = with pkgs; [curl vim git]; 56 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 50 57 systemd.tmpfiles.rules = let 51 58 u = config.services.tangled-knot.gitUser; 52 59 g = config.services.tangled-knot.gitUser;
+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{
+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
··· 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 + }
+16 -3
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" ··· 93 94 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 95 } 95 96 96 - 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) 97 103 if err != nil { 98 104 return err 99 105 } ··· 188 194 }() 189 195 190 196 logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 191 - 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))) 192 198 193 199 return nil 194 200 } 195 201 196 - func (s *Spindle) Router() http.Handler { 202 + func (s *Spindle) Router(oidcg *oidc.OidcTokenGenerator) http.Handler { 197 203 mux := chi.NewRouter() 198 204 199 205 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { ··· 204 210 w.Write([]byte(s.cfg.Server.Owner)) 205 211 }) 206 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) 207 216 208 217 mux.Mount("/xrpc", s.XrpcRouter()) 209 218 return mux ··· 240 249 241 250 if tpl.TriggerMetadata.Repo == nil { 242 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) 243 256 } 244 257 245 258 // filter by repos