From 5e0b9b3c6464e3cf48311e7cf2e5d42df206379d Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 22 May 2026 17:03:52 -0400 Subject: [PATCH 1/9] feat: per-ref policies on REST endpoints across all SDKs Extend the optional `ops`/`refs` JWT policy claims to every ref-mutating operation in the Go, TypeScript, and Python SDKs. Previously only the remote-URL token minting accepted ref policies; REST methods like create_branch, merge, commit, and notes silently dropped them. Adds `OP_NO_PUSH` / `OpNoPush` constant alongside the existing `OP_NO_FORCE_PUSH`, fixes a pre-existing inconsistency in the Python SDK where create_branch error responses didn't fall back to the `error` key, and verifies end-to-end against the acme staging tenant. Bumps: Go 0.8.0->0.9.0, TS 1.8.0->1.9.0, Python 1.9.0->1.10.0. --- packages/code-storage-go/client.go | 3 + packages/code-storage-go/commit.go | 2 +- packages/code-storage-go/diff_commit.go | 2 +- packages/code-storage-go/jwt_claims.go | 14 ++ packages/code-storage-go/jwt_claims_test.go | 36 +++ packages/code-storage-go/repo.go | 24 +- packages/code-storage-go/repo_test.go | 50 ++++ packages/code-storage-go/types.go | 63 ++++- packages/code-storage-go/version.go | 2 +- .../pierre_storage/__init__.py | 9 +- .../pierre_storage/auth.py | 18 +- .../pierre_storage/client.py | 4 + .../pierre_storage/repo.py | 88 ++++++- .../pierre_storage/types.py | 14 ++ .../pierre_storage/version.py | 2 +- packages/code-storage-python/pyproject.toml | 2 +- .../tests/ref_policies_live.py | 172 +++++++++++++ .../code-storage-python/tests/test_client.py | 30 +++ packages/code-storage-typescript/package.json | 2 +- packages/code-storage-typescript/src/index.ts | 29 +++ .../code-storage-typescript/src/jwt_claims.ts | 6 + packages/code-storage-typescript/src/types.ts | 44 +++- .../tests/index.test.ts | 52 ++++ .../tests/ref-policies-live.mjs | 232 ++++++++++++++++++ skills/code-storage/SKILL.md | 68 ++++- 25 files changed, 908 insertions(+), 60 deletions(-) create mode 100644 packages/code-storage-go/jwt_claims.go create mode 100644 packages/code-storage-go/jwt_claims_test.go create mode 100644 packages/code-storage-python/tests/ref_policies_live.py create mode 100644 packages/code-storage-typescript/src/jwt_claims.ts create mode 100644 packages/code-storage-typescript/tests/ref-policies-live.mjs diff --git a/packages/code-storage-go/client.go b/packages/code-storage-go/client.go index deeaca0..896e452 100644 --- a/packages/code-storage-go/client.go +++ b/packages/code-storage-go/client.go @@ -470,6 +470,9 @@ func (c *Client) generateJWT(repoID string, options RemoteURLOptions) (string, e "iat": issuedAt.Unix(), "exp": issuedAt.Add(ttl).Unix(), } + if len(options.Refs) > 0 { + claims["refs"] = encodeRefsClaim(options.Refs) + } if len(options.Ops) > 0 { claims["ops"] = options.Ops } diff --git a/packages/code-storage-go/commit.go b/packages/code-storage-go/commit.go index 4b45987..1b856ce 100644 --- a/packages/code-storage-go/commit.go +++ b/packages/code-storage-go/commit.go @@ -183,7 +183,7 @@ func (b *CommitBuilder) Send(ctx context.Context) (CommitResult, error) { } ttl := resolveCommitTTL(b.options.InvocationOptions, defaultTokenTTL) - jwtToken, err := b.client.generateJWT(b.repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := b.client.generateJWT(b.repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: b.options.Ops, Refs: b.options.Refs}) if err != nil { return CommitResult{}, err } diff --git a/packages/code-storage-go/diff_commit.go b/packages/code-storage-go/diff_commit.go index 8d6146a..a5a3149 100644 --- a/packages/code-storage-go/diff_commit.go +++ b/packages/code-storage-go/diff_commit.go @@ -67,7 +67,7 @@ func (d *diffCommitExecutor) send(ctx context.Context, repoID string) (CommitRes } ttl := resolveCommitTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := d.client.generateJWT(repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := d.client.generateJWT(repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return CommitResult{}, err } diff --git a/packages/code-storage-go/jwt_claims.go b/packages/code-storage-go/jwt_claims.go new file mode 100644 index 0000000..a45e4a1 --- /dev/null +++ b/packages/code-storage-go/jwt_claims.go @@ -0,0 +1,14 @@ +package storage + +// encodeRefsClaim builds the JWT `refs` claim as an array of [pattern, ops] tuples. +func encodeRefsClaim(refs RefPolicies) []any { + out := make([]any, len(refs)) + for i, rule := range refs { + opList := []string(rule.Ops) + if opList == nil { + opList = []string{} + } + out[i] = []any{rule.Pattern, opList} + } + return out +} diff --git a/packages/code-storage-go/jwt_claims_test.go b/packages/code-storage-go/jwt_claims_test.go new file mode 100644 index 0000000..98f8bfe --- /dev/null +++ b/packages/code-storage-go/jwt_claims_test.go @@ -0,0 +1,36 @@ +package storage + +import "testing" + +func TestEncodeRefsClaim(t *testing.T) { + encoded := encodeRefsClaim(RefPolicies{ + {Pattern: "refs/heads/main", Ops: Ops{OpNoPush}}, + {Pattern: "refs/heads/release/*", Ops: nil}, + {Pattern: "*", Ops: Ops{OpNoForcePush}}, + }) + + if len(encoded) != 3 { + t.Fatalf("expected 3 rules, got %d", len(encoded)) + } + + mainRule, ok := encoded[0].([]any) + if !ok { + t.Fatalf("unexpected rule type: %T", encoded[0]) + } + if mainRule[0] != "refs/heads/main" { + t.Fatalf("unexpected pattern: %v", mainRule[0]) + } + mainOps, ok := mainRule[1].([]string) + if !ok || len(mainOps) != 1 || mainOps[0] != OpNoPush { + t.Fatalf("unexpected ops: %v", mainRule[1]) + } + + releaseRule, ok := encoded[1].([]any) + if !ok { + t.Fatalf("unexpected rule type: %T", encoded[1]) + } + releaseOps, ok := releaseRule[1].([]string) + if !ok || len(releaseOps) != 0 { + t.Fatalf("expected empty ops slice, got %v", releaseRule[1]) + } +} diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index d55ebea..9aad721 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -536,12 +536,12 @@ func (r *Repo) GetNote(ctx context.Context, options GetNoteOptions) (GetNoteResu // CreateNote adds a git note. func (r *Repo) CreateNote(ctx context.Context, options CreateNoteOptions) (NoteWriteResult, error) { - return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author) + return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Ops, options.Refs) } // AppendNote appends to a git note. func (r *Repo) AppendNote(ctx context.Context, options AppendNoteOptions) (NoteWriteResult, error) { - return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author) + return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Ops, options.Refs) } // DeleteNote deletes a git note. @@ -552,7 +552,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return NoteWriteResult{}, err } @@ -592,7 +592,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW return result, nil } -func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor) (NoteWriteResult, error) { +func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor, ops Ops, refs RefPolicies) (NoteWriteResult, error) { sha = strings.TrimSpace(sha) if sha == "" { return NoteWriteResult{}, errors.New("note sha is required") @@ -604,7 +604,7 @@ func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, acti } ttl := resolveInvocationTTL(invocation, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: ops, Refs: refs}) if err != nil { return NoteWriteResult{}, err } @@ -862,7 +862,7 @@ func (r *Repo) Grep(ctx context.Context, options GrepOptions) (GrepResult, error // PullUpstream triggers a pull-upstream operation. func (r *Repo) PullUpstream(ctx context.Context, options PullUpstreamOptions) error { ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return err } @@ -897,7 +897,7 @@ func (r *Repo) CreateBranch(ctx context.Context, options CreateBranchOptions) (C } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return CreateBranchResult{}, err } @@ -944,7 +944,7 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return DeleteBranchResult{}, err } @@ -1020,7 +1020,7 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return MergeResult{}, err } @@ -1081,7 +1081,7 @@ func (r *Repo) CreateTag(ctx context.Context, options CreateTagOptions) (CreateT } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return CreateTagResult{}, err } @@ -1116,7 +1116,7 @@ func (r *Repo) DeleteTag(ctx context.Context, options DeleteTagOptions) (DeleteT } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return DeleteTagResult{}, err } @@ -1159,7 +1159,7 @@ func (r *Repo) RestoreCommit(ctx context.Context, options RestoreCommitOptions) } ttl := resolveCommitTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) if err != nil { return RestoreCommitResult{}, err } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 7427cff..c845b5e 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -104,6 +104,56 @@ func TestRemoteURLOps(t *testing.T) { }) } +func TestRemoteURLRefs(t *testing.T) { + client, err := NewClient(Options{Name: "acme", Key: testKey, StorageBaseURL: "acme.code.storage"}) + if err != nil { + t.Fatalf("client error: %v", err) + } + repo := &Repo{ID: "repo-1", DefaultBranch: "main", client: client} + + t.Run("includes refs in JWT when provided", func(t *testing.T) { + remote, err := repo.RemoteURL(nil, RemoteURLOptions{ + Refs: RefPolicies{ + {Pattern: "refs/heads/main", Ops: Ops{OpNoPush}}, + {Pattern: "*", Ops: Ops{OpNoForcePush}}, + }, + }) + if err != nil { + t.Fatalf("remote url error: %v", err) + } + claims := parseJWTFromURL(t, remote) + refs, ok := claims["refs"].([]interface{}) + if !ok { + t.Fatalf("expected refs claim to be a list, got %T", claims["refs"]) + } + if len(refs) != 2 { + t.Fatalf("expected 2 ref rules, got %d", len(refs)) + } + mainRule, ok := refs[0].([]interface{}) + if !ok || len(mainRule) != 2 { + t.Fatalf("unexpected main rule shape: %v", refs[0]) + } + if mainRule[0] != "refs/heads/main" { + t.Fatalf("unexpected pattern: %v", mainRule[0]) + } + mainOps, ok := mainRule[1].([]interface{}) + if !ok || len(mainOps) != 1 || mainOps[0] != "no-push" { + t.Fatalf("unexpected main ops: %v", mainRule[1]) + } + }) + + t.Run("omits refs from JWT when not provided", func(t *testing.T) { + remote, err := repo.RemoteURL(nil, RemoteURLOptions{}) + if err != nil { + t.Fatalf("remote url error: %v", err) + } + claims := parseJWTFromURL(t, remote) + if _, ok := claims["refs"]; ok { + t.Fatalf("expected no refs claim") + } + }) +} + func TestListFilesEphemeral(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/files" { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 0edc0dc..f66f132 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -35,16 +35,29 @@ type Op = string const ( OpNoForcePush Op = "no-force-push" + OpNoPush Op = "no-push" ) // Ops is a list of policy operations. type Ops []Op +// RefPolicy is a single ordered ref-matching policy rule (first match wins). +type RefPolicy struct { + Pattern string + Ops Ops +} + +// RefPolicies is an ordered list of per-ref policy rules for the JWT `refs` claim. +type RefPolicies []RefPolicy + // RemoteURLOptions configure token generation for remote URLs. type RemoteURLOptions struct { Permissions []Permission TTL time.Duration - Ops Ops + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // InvocationOptions holds common request options. @@ -232,6 +245,10 @@ type ArchiveOptions struct { type PullUpstreamOptions struct { InvocationOptions Ref string + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // ListFilesOptions configures list files. @@ -309,6 +326,10 @@ type CreateBranchOptions struct { TargetBranch string BaseIsEphemeral bool TargetIsEphemeral bool + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // CreateBranchResult describes branch creation result. @@ -324,6 +345,10 @@ type DeleteBranchOptions struct { InvocationOptions Name string Ephemeral *bool + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // DeleteBranchResult describes branch deletion result. @@ -357,6 +382,10 @@ type MergeOptions struct { AllowUnrelatedHistories bool // Squash is incompatible with MergeStrategyFFOnly. Squash bool + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // MergeResultStatus describes a merge operation outcome. @@ -422,6 +451,10 @@ type CreateTagOptions struct { InvocationOptions Name string Target string + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // CreateTagResult describes tag creation result. @@ -435,6 +468,10 @@ type CreateTagResult struct { type DeleteTagOptions struct { InvocationOptions Name string + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // DeleteTagResult describes tag deletion result. @@ -544,6 +581,10 @@ type CreateNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // AppendNoteOptions configures note append. @@ -553,6 +594,10 @@ type AppendNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // DeleteNoteOptions configures note delete. @@ -561,6 +606,10 @@ type DeleteNoteOptions struct { SHA string ExpectedRefSHA string Author *NoteAuthor + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // NoteWriteResult describes note write response. @@ -799,6 +848,10 @@ type CommitOptions struct { EphemeralBase bool Author CommitSignature Committer *CommitSignature + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // CommitFromDiffOptions configures diff commit. @@ -813,6 +866,10 @@ type CommitFromDiffOptions struct { EphemeralBase bool Author CommitSignature Committer *CommitSignature + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // RestoreCommitOptions configures restore commit. @@ -824,6 +881,10 @@ type RestoreCommitOptions struct { ExpectedHeadSHA string Author CommitSignature Committer *CommitSignature + // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). + Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // RestoreCommitResult describes restore commit. diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index 3a2ef0d..e00ce03 100644 --- a/packages/code-storage-go/version.go +++ b/packages/code-storage-go/version.go @@ -2,7 +2,7 @@ package storage const ( PackageName = "code-storage-go-sdk" - PackageVersion = "0.8.0" + PackageVersion = "0.9.0" ) func userAgent() string { diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index f895975..6b5e5a7 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -3,7 +3,7 @@ A Python SDK for interacting with Pierre's git storage system. """ -from pierre_storage.auth import generate_jwt +from pierre_storage.auth import encode_refs_claim, generate_jwt from pierre_storage.client import GitStorage, create_client from pierre_storage.errors import ApiError, RefUpdateError from pierre_storage.types import ( @@ -31,7 +31,10 @@ GitStorageOptions, Op, OP_NO_FORCE_PUSH, + OP_NO_PUSH, Ops, + RefPolicy, + Refs, GrepFileMatch, GrepLine, GrepResult, @@ -64,6 +67,7 @@ "GitStorage", "create_client", # Auth + "encode_refs_claim", "generate_jwt", # Errors "ApiError", @@ -96,7 +100,10 @@ "GitStorageOptions", "Op", "OP_NO_FORCE_PUSH", + "OP_NO_PUSH", "Ops", + "RefPolicy", + "Refs", "ListBranchesResult", "ListCommitsResult", "ListFilesResult", diff --git a/packages/code-storage-python/pierre_storage/auth.py b/packages/code-storage-python/pierre_storage/auth.py index e3c8287..f8f69a8 100644 --- a/packages/code-storage-python/pierre_storage/auth.py +++ b/packages/code-storage-python/pierre_storage/auth.py @@ -1,11 +1,23 @@ """JWT authentication utilities for Pierre Git Storage SDK.""" import time -from typing import List, Optional +from typing import List, Optional, Union import jwt from cryptography.hazmat.primitives import serialization +from pierre_storage.types import Refs + + +def encode_refs_claim(refs: Refs) -> List[List[Union[str, List[str]]]]: + """Encode per-ref policy rules for the JWT ``refs`` claim.""" + out: List[List[Union[str, List[str]]]] = [] + for rule in refs: + pattern = rule["pattern"] + ops = rule.get("ops") + out.append([pattern, list(ops) if ops is not None else []]) + return out + def generate_jwt( key_pem: str, @@ -14,6 +26,7 @@ def generate_jwt( scopes: Optional[List[str]] = None, ttl: int = 31536000, # 1 year default ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Generate a JWT token for Git storage authentication. @@ -24,6 +37,7 @@ def generate_jwt( scopes: List of permission scopes (defaults to ['git:write', 'git:read']) ttl: Time-to-live in seconds (defaults to 1 year) ops: List of policy operations (e.g., ['no-force-push']) + refs: Ordered per-ref policy rules (first match wins) Returns: Signed JWT token string @@ -43,6 +57,8 @@ def generate_jwt( "iat": now, "exp": now + ttl, } + if refs: + payload["refs"] = encode_refs_claim(refs) if ops: payload["ops"] = ops diff --git a/packages/code-storage-python/pierre_storage/client.py b/packages/code-storage-python/pierre_storage/client.py index 236037c..b7d24fa 100644 --- a/packages/code-storage-python/pierre_storage/client.py +++ b/packages/code-storage-python/pierre_storage/client.py @@ -618,6 +618,7 @@ def _generate_jwt( permissions = ["git:write", "git:read"] ttl: int = 31536000 # 1 year default ops: Optional[List[str]] = None + refs = None if options: if "permissions" in options: @@ -628,6 +629,8 @@ def _generate_jwt( ttl = option_ttl if "ops" in options: ops = options["ops"] + if "refs" in options: + refs = options["refs"] elif "default_ttl" in self.options: default_ttl = self.options["default_ttl"] if isinstance(default_ttl, int): @@ -640,6 +643,7 @@ def _generate_jwt( permissions, ttl, ops, + refs, ) diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 0426d9f..ccd17e1 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -50,6 +50,7 @@ NoteReadResult, NoteWriteResult, RefUpdate, + Refs, RestoreCommitResult, TagInfo, ) @@ -59,6 +60,21 @@ ZERO_DATETIME_UTC = datetime.min.replace(tzinfo=timezone.utc) +def _build_jwt_options( + permissions: List[str], + ttl: int, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, +) -> Dict[str, Any]: + """Assemble the JWT options dict, attaching ref policies when supplied.""" + options: Dict[str, Any] = {"permissions": permissions, "ttl": ttl} + if ops: + options["ops"] = ops + if refs: + options["refs"] = refs + return options + + class StreamingResponse: """Stream wrapper that keeps the HTTP client alive until closed.""" @@ -208,6 +224,7 @@ async def get_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get remote URL for Git operations. @@ -215,6 +232,7 @@ async def get_remote_url( permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds ops: List of policy operations (e.g., ["no-force-push"]) + refs: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT @@ -226,6 +244,8 @@ async def get_remote_url( options["ttl"] = ttl if ops is not None: options["ops"] = ops + if refs is not None: + options["refs"] = refs jwt_token = self.generate_jwt(self._id, options if options else None) url = f"https://t:{jwt_token}@{self.storage_base_url}/{self._id}.git" @@ -237,6 +257,7 @@ async def get_import_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get import remote URL for Git operations. @@ -244,11 +265,14 @@ async def get_import_remote_url( permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds ops: List of policy operations (e.g., ["no-force-push"]) + refs: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT pointing to import namespace """ - url = await self.get_remote_url(permissions=permissions, ttl=ttl, ops=ops) + url = await self.get_remote_url( + permissions=permissions, ttl=ttl, ops=ops, refs=refs + ) return url.replace(".git", "+import.git") async def get_ephemeral_remote_url( @@ -257,6 +281,7 @@ async def get_ephemeral_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get ephemeral remote URL for Git operations. @@ -264,11 +289,14 @@ async def get_ephemeral_remote_url( permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds ops: List of policy operations (e.g., ["no-force-push"]) + refs: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT pointing to ephemeral namespace """ - url = await self.get_remote_url(permissions=permissions, ttl=ttl, ops=ops) + url = await self.get_remote_url( + permissions=permissions, ttl=ttl, ops=ops, refs=refs + ) return url.replace(".git", "+ephemeral.git") async def get_file_stream( @@ -564,6 +592,8 @@ async def create_branch( base_is_ephemeral: bool = False, target_is_ephemeral: bool = False, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> CreateBranchResult: """Create or promote a branch. @@ -595,7 +625,7 @@ async def create_branch( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, ops, refs), ) payload: Dict[str, Any] = { @@ -628,6 +658,8 @@ async def create_branch( error_data = response.json() if isinstance(error_data, dict) and error_data.get("message"): message = str(error_data["message"]) + elif isinstance(error_data, dict) and error_data.get("error"): + message = str(error_data["error"]) else: message = f"{message} with HTTP {response.status_code}" except Exception: @@ -652,6 +684,8 @@ async def delete_branch( name: str, ephemeral: Optional[bool] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> DeleteBranchResult: """Delete a branch. @@ -667,7 +701,7 @@ async def delete_branch( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, ops, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/branches" @@ -725,6 +759,8 @@ async def merge( allow_unrelated_histories: Optional[bool] = None, squash: Optional[bool] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> MergeBranchesResult: """Merge a source branch into a target branch.""" source_branch_clean = source_branch.strip() @@ -777,7 +813,7 @@ async def merge( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, ops, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/merge" @@ -885,6 +921,8 @@ async def create_tag( name: str, target: str, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> CreateTagResult: """Create a tag.""" name_clean = name.strip() @@ -900,7 +938,7 @@ async def create_tag( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, ops, refs), ) payload = {"name": name_clean, "target": target_clean} @@ -944,6 +982,8 @@ async def delete_tag( *, name: str, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> DeleteTagResult: """Delete a tag.""" name_clean = name.strip() @@ -955,7 +995,7 @@ async def delete_tag( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:read", "git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:read", "git:write"], ttl_value, ops, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/tags" @@ -1276,6 +1316,8 @@ async def create_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> NoteWriteResult: """Create a git note.""" return await self._write_note( @@ -1286,6 +1328,8 @@ async def create_note( expected_ref_sha=expected_ref_sha, author=author, ttl=ttl, + ops=ops, + refs=refs, ) async def append_note( @@ -1296,6 +1340,8 @@ async def append_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> NoteWriteResult: """Append to a git note.""" return await self._write_note( @@ -1306,6 +1352,8 @@ async def append_note( expected_ref_sha=expected_ref_sha, author=author, ttl=ttl, + ops=ops, + refs=refs, ) async def delete_note( @@ -1315,6 +1363,8 @@ async def delete_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> NoteWriteResult: """Delete a git note.""" sha_clean = sha.strip() @@ -1322,7 +1372,7 @@ async def delete_note( raise ValueError("delete_note sha is required") ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl}) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ops, refs)) payload: Dict[str, Any] = {"sha": sha_clean} if expected_ref_sha and expected_ref_sha.strip(): @@ -1633,18 +1683,22 @@ async def pull_upstream( *, ref: Optional[str] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> None: """Pull from upstream repository. Args: ref: Git ref to pull ttl: Token TTL in seconds + ops: Repo-wide policy ops + refs: Ordered per-ref policy rules (first match wins) Raises: ApiError: If pull fails """ ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl}) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ops, refs)) body = {} if ref: @@ -1678,6 +1732,8 @@ async def restore_commit( expected_head_sha: Optional[str] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> RestoreCommitResult: """Restore a previous commit. @@ -1713,7 +1769,7 @@ async def restore_commit( raise ValueError("restoreCommit author name and email are required") ttl = ttl or resolve_commit_ttl_seconds(None) - jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl}) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ops, refs)) metadata: Dict[str, Any] = { "target_branch": target_branch, @@ -1817,6 +1873,8 @@ def create_commit( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> CommitBuilder: """Create a new commit builder. @@ -1856,7 +1914,7 @@ def create_commit( def get_auth_token() -> str: return self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl}, + _build_jwt_options(["git:write"], ttl, ops, refs), ) return CommitBuilderImpl( @@ -1879,6 +1937,8 @@ async def create_commit_from_diff( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> CommitResult: """Create a commit by applying a unified diff.""" if diff is None: @@ -1906,7 +1966,7 @@ async def create_commit_from_diff( def get_auth_token() -> str: return self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, ops, refs), ) return await send_diff_commit_request( @@ -1927,6 +1987,8 @@ async def _write_note( expected_ref_sha: Optional[str], author: Optional[CommitSignature], ttl: Optional[int], + ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> NoteWriteResult: sha_clean = sha.strip() if not sha_clean: @@ -1937,7 +1999,7 @@ async def _write_note( raise ValueError(f"{action_label} note is required") ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl}) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ops, refs)) payload: Dict[str, Any] = { "sha": sha_clean, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 6af1573..b5f3671 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -45,11 +45,22 @@ class GitStorageOptions(TypedDict, total=False): Op = str OP_NO_FORCE_PUSH: Op = "no-force-push" +OP_NO_PUSH: Op = "no-push" # Ops is a list of policy operations. Ops = List[Op] +class RefPolicy(TypedDict, total=False): + """A single ordered ref-matching policy rule (first match wins).""" + + pattern: str # required + ops: List[Op] + + +Refs = List[RefPolicy] + + class PublicGitHubBaseRepoAuth(TypedDict): """Authentication mode for GitHub base repositories.""" @@ -609,6 +620,7 @@ async def get_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get the remote URL for the repository.""" ... @@ -619,6 +631,7 @@ async def get_import_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get the import remote URL for the repository.""" ... @@ -629,6 +642,7 @@ async def get_ephemeral_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get the ephemeral remote URL for the repository.""" ... diff --git a/packages/code-storage-python/pierre_storage/version.py b/packages/code-storage-python/pierre_storage/version.py index c678598..ac3cde2 100644 --- a/packages/code-storage-python/pierre_storage/version.py +++ b/packages/code-storage-python/pierre_storage/version.py @@ -1,7 +1,7 @@ """Version information for Pierre Storage SDK.""" PACKAGE_NAME = "code-storage-py-sdk" -PACKAGE_VERSION = "1.9.0" +PACKAGE_VERSION = "1.10.0" def get_user_agent() -> str: diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index 83d9466..1eab32e 100644 --- a/packages/code-storage-python/pyproject.toml +++ b/packages/code-storage-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pierre-storage" -version = "1.9.0" +version = "1.10.0" description = "Pierre Git Storage SDK for Python" readme = "README.md" license = "MIT" diff --git a/packages/code-storage-python/tests/ref_policies_live.py b/packages/code-storage-python/tests/ref_policies_live.py new file mode 100644 index 0000000..4859bec --- /dev/null +++ b/packages/code-storage-python/tests/ref_policies_live.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Live ref-policy smoke test against a running local git3p stack.""" + +from __future__ import annotations + +import asyncio +import os +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path + +import jwt + +from pierre_storage import OP_NO_PUSH, create_client + +def _default_key_path() -> Path: + if env := os.environ.get("GIT3P_KEY_PATH"): + return Path(env) + here = Path(__file__).resolve() + candidates = [ + here.parents[3] / "git3p-backend/hack/test-scripts/dev-keys/private.pem", + here.parents[4] / "monorepo/git3p-backend/hack/test-scripts/dev-keys/private.pem", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[-1] + + +KEY_PATH = _default_key_path() + +API_BASE = os.environ.get("GIT3P_API_URL", "http://127.0.0.1:8081") +STORAGE_HOST = os.environ.get("GIT3P_GIT_URL", "127.0.0.1:8080").replace("http://", "").replace("https://", "") +ISSUER = os.environ.get("GIT3P_ISSUER", "local") + + +def git(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + text=True, + capture_output=True, + env={ + **os.environ, + "GIT_TERMINAL_PROMPT": "0", + "GIT_CONFIG_NOSYSTEM": "1", + "HOME": str(cwd), + }, + check=False, + ) + + +def build_git_remote(repo_id: str, token: str) -> str: + """Build a Git remote URL (HTTP for local dev; JWT may contain '@').""" + host = STORAGE_HOST + if "://" in host: + base = host.rstrip("/") + elif host.startswith("127.0.0.1") or host.startswith("localhost"): + base = f"http://{host}" + else: + base = f"https://{host}" + return f"{base}/{repo_id}.git".replace("://", f"://t:{token}@", 1) + + +def decode_refs(token: str) -> list: + payload = jwt.decode(token, options={"verify_signature": False}) + return payload.get("refs", []) + + +async def main() -> int: + if not KEY_PATH.exists(): + print(f"FAIL: signing key not found at {KEY_PATH}", file=sys.stderr) + return 1 + + key_pem = KEY_PATH.read_text() + repo_id = f"sdk-refpol-py-{int(time.time())}-{uuid.uuid4().hex[:6]}" + + client = create_client( + { + "name": ISSUER, + "key": key_pem, + "api_base_url": API_BASE, + "storage_base_url": STORAGE_HOST, + } + ) + + print(f"▶ Python ref-policy live test (repo={repo_id})") + repo = await client.create_repo(id=repo_id, default_branch="main") + print(" ✓ repo created") + + open_url = await repo.get_remote_url(permissions=["git:read", "git:write"], ttl=1800) + restricted_url = await repo.get_remote_url( + permissions=["git:read", "git:write"], + ttl=600, + refs=[ + {"pattern": "refs/heads/main", "ops": [OP_NO_PUSH]}, + ], + ) + + open_token = open_url.split("://", 1)[1].rsplit("@", 1)[0].split(":", 1)[1] + restricted_token = restricted_url.split("://", 1)[1].rsplit("@", 1)[0].split(":", 1)[1] + refs_claim = decode_refs(restricted_token) + if refs_claim != [["refs/heads/main", ["no-push"]]]: + print(f"FAIL: unexpected refs claim in JWT: {refs_claim}", file=sys.stderr) + return 1 + print(f" ✓ JWT refs claim: {refs_claim}") + + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + for cmd in ( + ["init", "-b", "main"], + ["config", "user.email", "refpol@pierre.invalid"], + ["config", "user.name", "RefPol Live"], + ["config", "commit.gpgsign", "false"], + ): + git(*cmd, cwd=work) + + (work / "README.md").write_text("hello\n") + git("add", "README.md", cwd=work) + git("commit", "-m", "initial", cwd=work) + + git("remote", "add", "origin", build_git_remote(repo.id, open_token), cwd=work) + push = git("push", "-u", "origin", "main", cwd=work) + if push.returncode != 0: + print(f"FAIL: seed push failed:\n{push.stdout}{push.stderr}", file=sys.stderr) + return 1 + print(" ✓ seeded main via open token") + + git("checkout", "-b", "feature/allowed", cwd=work) + (work / "README.md").write_text("hello\nfeature\n") + git("add", "README.md", cwd=work) + git("commit", "-m", "feature commit", cwd=work) + + git("remote", "set-url", "origin", build_git_remote(repo.id, restricted_token), cwd=work) + feature_push = git("push", "-u", "origin", "feature/allowed", cwd=work) + if feature_push.returncode != 0: + print( + f"FAIL: feature push should succeed:\n{feature_push.stdout}{feature_push.stderr}", + file=sys.stderr, + ) + return 1 + print(" ✓ feature branch push allowed") + + git("checkout", "main", cwd=work) + (work / "README.md").write_text("hello\nblocked\n") + git("add", "README.md", cwd=work) + git("commit", "-m", "main blocked attempt", cwd=work) + main_push = git("push", "origin", "main", cwd=work) + combined = f"{main_push.stdout}{main_push.stderr}" + if main_push.returncode == 0: + print("FAIL: push to main should be denied by no-push policy", file=sys.stderr) + return 1 + if "denied by policy" not in combined: + print(f"FAIL: expected 'denied by policy' in push output:\n{combined}", file=sys.stderr) + return 1 + print(" ✓ main push denied by policy") + + try: + await client.delete_repo(id=repo_id) + print(" ✓ repo deleted") + except Exception as exc: # noqa: BLE001 + print(f" (cleanup warning: {exc})") + + print("✅ Python ref-policy live test passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/packages/code-storage-python/tests/test_client.py b/packages/code-storage-python/tests/test_client.py index 1a38dd5..2434aa2 100644 --- a/packages/code-storage-python/tests/test_client.py +++ b/packages/code-storage-python/tests/test_client.py @@ -1133,6 +1133,36 @@ def test_generate_jwt_with_empty_ops(self, test_key: str) -> None: payload = jwt.decode(token, options={"verify_signature": False}) assert "ops" not in payload + def test_generate_jwt_with_refs(self, test_key: str) -> None: + """Test JWT generation with per-ref policy rules.""" + token = generate_jwt( + key_pem=test_key, + issuer="test-customer", + repo_id="test-repo", + refs=[ + {"pattern": "refs/heads/main", "ops": ["no-push"]}, + {"pattern": "*", "ops": ["no-force-push"]}, + ], + ) + + payload = jwt.decode(token, options={"verify_signature": False}) + assert payload["refs"] == [ + ["refs/heads/main", ["no-push"]], + ["*", ["no-force-push"]], + ] + assert "ops" not in payload + + def test_generate_jwt_without_refs(self, test_key: str) -> None: + """Test JWT generation omits refs when not provided.""" + token = generate_jwt( + key_pem=test_key, + issuer="test-customer", + repo_id="test-repo", + ) + + payload = jwt.decode(token, options={"verify_signature": False}) + assert "refs" not in payload + def test_generate_jwt_default_ttl(self, test_key: str) -> None: """Test JWT generation uses 1 year default TTL.""" token = generate_jwt( diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 52228e6..897fb81 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.8.0", + "version": "1.9.0", "description": "Pierre Git Storage SDK", "repository": { "type": "git", diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index cfcd87f..6d76ea1 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -4,6 +4,7 @@ * A TypeScript SDK for interacting with Pierre's git storage system */ import { SignJWT, importPKCS8 } from 'jose'; +import { encodeRefsClaim } from './jwt_claims'; import snakecaseKeys from 'snakecase-keys'; import { @@ -143,6 +144,7 @@ export { RefUpdateError } from './errors'; export { ApiError } from './fetch'; // Import additional types from types.ts export * from './types'; +export { encodeRefsClaim } from './jwt_claims'; // Export webhook validation utilities export { @@ -1099,6 +1101,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const body = buildNoteWriteBody(sha, note, 'add', { @@ -1144,6 +1148,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const body = buildNoteWriteBody(sha, note, 'append', { @@ -1184,6 +1190,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const body: Record = { @@ -1392,6 +1400,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const body: Record = {}; @@ -1431,6 +1441,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const body: Record = { @@ -1472,6 +1484,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const body: Record = { name }; @@ -1513,6 +1527,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const body: Record = { @@ -1589,6 +1605,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const response = await this.api.post( @@ -1612,6 +1630,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:read', 'git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const response = await this.api.delete( @@ -1651,6 +1671,8 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); const metadata: Record = { @@ -1728,6 +1750,8 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); return createCommitBuilder({ @@ -1754,6 +1778,8 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, + ops: options.ops, + refs: options.refs, }); return sendCommitFromDiff({ @@ -2134,6 +2160,9 @@ export class GitStorage { scopes: permissions, iat: now, exp: now + ttl, + ...(options?.refs && options.refs.length > 0 + ? { refs: encodeRefsClaim(options.refs) } + : {}), ...(options?.ops && options.ops.length > 0 ? { ops: options.ops } : {}), }; diff --git a/packages/code-storage-typescript/src/jwt_claims.ts b/packages/code-storage-typescript/src/jwt_claims.ts new file mode 100644 index 0000000..fa7ba3d --- /dev/null +++ b/packages/code-storage-typescript/src/jwt_claims.ts @@ -0,0 +1,6 @@ +import type { RefPolicies } from './types'; + +/** Encode per-ref policy rules for the JWT `refs` claim (array of [pattern, ops] tuples). */ +export function encodeRefsClaim(refs: RefPolicies): [string, string[]][] { + return refs.map(({ pattern, ops }) => [pattern, ops ?? []]); +} diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index b09f099..bddea89 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -50,13 +50,31 @@ export type Op = string; export const OP_NO_FORCE_PUSH: Op = 'no-force-push'; +export const OP_NO_PUSH: Op = 'no-push'; + /** A list of policy operations. */ export type Ops = Op[]; -export interface GetRemoteURLOptions { +/** A single ordered ref-matching policy rule (first match wins). */ +export interface RefPolicy { + pattern: string; + ops?: Ops; +} + +/** Ordered per-ref policy rules for the JWT `refs` claim. */ +export type RefPolicies = RefPolicy[]; + +/** Optional ref policies that can be attached to a minted JWT for any ref-mutating call. */ +export interface PolicyOptions { + /** Repo-wide policy ops (legacy; folded into a catch-all `*` rule on verify). */ + ops?: Ops; + /** Per-ref policy rules evaluated in declaration order (first match wins). */ + refs?: RefPolicies; +} + +export interface GetRemoteURLOptions extends PolicyOptions { permissions?: ('git:write' | 'git:read' | 'repo:write' | 'org:read')[]; ttl?: number; - ops?: Ops; } export interface Repo { @@ -262,7 +280,7 @@ export interface ArchiveOptions extends GitStorageInvocationOptions { archivePrefix?: string; } -export interface PullUpstreamOptions extends GitStorageInvocationOptions { +export interface PullUpstreamOptions extends GitStorageInvocationOptions, PolicyOptions { ref?: string; } @@ -335,7 +353,7 @@ export interface ListBranchesResult { } // Create Branch API types -export interface CreateBranchOptions extends GitStorageInvocationOptions { +export interface CreateBranchOptions extends GitStorageInvocationOptions, PolicyOptions { baseRef?: string; /** @deprecated Use baseRef instead. */ baseBranch?: string; @@ -353,7 +371,7 @@ export interface CreateBranchResult { commitSha?: string; } -export interface DeleteBranchOptions extends GitStorageInvocationOptions { +export interface DeleteBranchOptions extends GitStorageInvocationOptions, PolicyOptions { name: string; ephemeral?: boolean; } @@ -387,7 +405,7 @@ export interface ListTagsResult { hasMore: boolean; } -export interface CreateTagOptions extends GitStorageInvocationOptions { +export interface CreateTagOptions extends GitStorageInvocationOptions, PolicyOptions { name: string; target: string; } @@ -400,7 +418,7 @@ export interface CreateTagResult { message: string; } -export interface DeleteTagOptions extends GitStorageInvocationOptions { +export interface DeleteTagOptions extends GitStorageInvocationOptions, PolicyOptions { name: string; } @@ -499,7 +517,7 @@ export interface GetNoteResult { refSha: string; } -interface NoteWriteBaseOptions extends GitStorageInvocationOptions { +interface NoteWriteBaseOptions extends GitStorageInvocationOptions, PolicyOptions { sha: string; note: string; expectedRefSha?: string; @@ -510,7 +528,7 @@ export type CreateNoteOptions = NoteWriteBaseOptions; export type AppendNoteOptions = NoteWriteBaseOptions; -export interface DeleteNoteOptions extends GitStorageInvocationOptions { +export interface DeleteNoteOptions extends GitStorageInvocationOptions, PolicyOptions { sha: string; expectedRefSha?: string; author?: CommitSignature; @@ -669,7 +687,7 @@ export interface FileDiff extends DiffFileBase { export interface FilteredFile extends DiffFileBase {} -interface CreateCommitBaseOptions extends GitStorageInvocationOptions { +interface CreateCommitBaseOptions extends GitStorageInvocationOptions, PolicyOptions { commitMessage: string; expectedHeadSha?: string; baseBranch?: string; @@ -772,7 +790,7 @@ export interface CommitBuilder { export type DiffSource = CommitFileSource; export interface CreateCommitFromDiffOptions - extends GitStorageInvocationOptions { + extends GitStorageInvocationOptions, PolicyOptions { targetBranch: string; commitMessage: string; diff: DiffSource; @@ -821,7 +839,7 @@ export type MergeResultLabel = | 'no_op' | 'unknown'; -export interface MergeOptions extends GitStorageInvocationOptions { +export interface MergeOptions extends GitStorageInvocationOptions, PolicyOptions { sourceBranch: string; sourceIsEphemeral?: boolean; targetBranch: string; @@ -863,7 +881,7 @@ export interface MergeResult { promotedCommits: number; } -export interface RestoreCommitOptions extends GitStorageInvocationOptions { +export interface RestoreCommitOptions extends GitStorageInvocationOptions, PolicyOptions { targetBranch: string; targetCommitSha: string; commitMessage?: string; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index fb99cb4..ba742a6 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -2545,6 +2545,58 @@ describe('GitStorage', () => { expect(payload).not.toHaveProperty('ops'); }); + it('should include refs in JWT when provided', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + + const url = await repo.getRemoteURL({ + refs: [ + { pattern: 'refs/heads/main', ops: ['no-push'] }, + { pattern: '*', ops: ['no-force-push'] }, + ], + }); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload.refs).toEqual([ + ['refs/heads/main', ['no-push']], + ['*', ['no-force-push']], + ]); + expect(payload).not.toHaveProperty('ops'); + }); + + it('should encode allow-rules with empty ops array in refs claim', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + + const url = await repo.getRemoteURL({ + refs: [ + { pattern: 'refs/heads/release/*', ops: [] }, + { pattern: '*', ops: ['no-push'] }, + ], + }); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload.refs).toEqual([ + ['refs/heads/release/*', []], + ['*', ['no-push']], + ]); + }); + + it('should not include refs in JWT when not provided', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + const url = await repo.getRemoteURL(); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload).not.toHaveProperty('refs'); + }); + it('should include repo ID in URL path and JWT payload', async () => { const store = new GitStorage({ name: 'v0', key }); const customRepoId = 'my-custom-repo'; diff --git a/packages/code-storage-typescript/tests/ref-policies-live.mjs b/packages/code-storage-typescript/tests/ref-policies-live.mjs new file mode 100644 index 0000000..7c5d4e7 --- /dev/null +++ b/packages/code-storage-typescript/tests/ref-policies-live.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * Live ref-policy smoke test against a running local git3p stack. + * Patches JWT signing for RS256 dev keys (same as full-workflow.js). + */ + +import { SignJWT, importPKCS8 } from 'jose'; +import { createPrivateKey } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { encodeRefsClaim } from '../src/jwt_claims.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const defaultKeyPath = path.resolve( + __dirname, + '../../../git3p-backend/hack/test-scripts/dev-keys/private.pem' +); +const monorepoKeyPath = path.resolve( + __dirname, + '../../../../monorepo/git3p-backend/hack/test-scripts/dev-keys/private.pem' +); + +const keyPath = process.env.GIT3P_KEY_PATH + ? path.resolve(process.env.GIT3P_KEY_PATH) + : existsSync(defaultKeyPath) + ? defaultKeyPath + : monorepoKeyPath; + +const apiBaseUrl = process.env.GIT3P_API_URL ?? 'http://127.0.0.1:8081'; +const storageBaseUrl = (process.env.GIT3P_GIT_URL ?? '127.0.0.1:8080') + .replace(/^https?:\/\//, ''); +const issuer = process.env.GIT3P_ISSUER ?? 'local'; +const keyId = process.env.GIT3P_KEY_ID ?? 'dev-key-001'; + +function decodeJwtPayload(token) { + const part = token.split('.')[1]; + return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); +} + +async function resolveSigningKey(pem) { + const keyObject = createPrivateKey({ key: pem, format: 'pem' }); + const alg = keyObject.asymmetricKeyType === 'rsa' ? 'RS256' : 'ES256'; + return { key: await importPKCS8(pem, alg), alg }; +} + +function patchGitStorage(GitStorage) { + GitStorage.prototype.generateJWT = async function patchedGenerateJWT( + repoId, + options + ) { + const permissions = options?.permissions ?? ['git:write', 'git:read']; + const ttl = options?.ttl ?? 365 * 24 * 60 * 60; + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: this.options.name, + sub: '@pierre/storage', + repo: repoId, + scopes: permissions, + iat: now, + exp: now + ttl, + ...(options?.refs?.length ? { refs: encodeRefsClaim(options.refs) } : {}), + ...(options?.ops?.length ? { ops: options.ops } : {}), + }; + const { key, alg } = await resolveSigningKey(this.options.key); + const header = { alg, typ: 'JWT', kid: keyId }; + return new SignJWT(payload).setProtectedHeader(header).sign(key); + }; +} + +function git(args, { cwd }) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_CONFIG_NOSYSTEM: '1', + HOME: cwd, + }, + }); +} + +function gitAllowFail(args, { cwd }) { + try { + const stdout = git(args, { cwd }); + return { code: 0, output: stdout }; + } catch (err) { + return { + code: err.status ?? 1, + output: `${err.stdout ?? ''}${err.stderr ?? ''}`, + }; + } +} + +function tokenFromRemoteUrl(url) { + const afterScheme = url.split('://', 2)[1]; + const at = afterScheme.lastIndexOf('@'); + const userinfo = afterScheme.slice(0, at); + return userinfo.slice(userinfo.indexOf(':') + 1); +} + +function buildGitRemote(repoId, token) { + const host = storageBaseUrl; + const base = + host.includes('://') + ? host.replace(/\/$/, '') + : host.startsWith('127.0.0.1') || host.startsWith('localhost') + ? `http://${host}` + : `https://${host}`; + return `${base}/${repoId}.git`.replace('://', `://t:${token}@`); +} + +async function loadGitStorage() { + const candidates = [ + path.resolve(__dirname, '../dist/index.js'), + path.resolve(__dirname, '../dist/index.mjs'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + const mod = await import(pathToFileURL(candidate).href); + return mod.GitStorage ?? mod.default; + } + } + const mod = await import('../src/index.ts'); + return mod.GitStorage; +} + +async function main() { + if (!existsSync(keyPath)) { + console.error(`FAIL: signing key not found at ${keyPath}`); + process.exit(1); + } + + const key = readFileSync(keyPath, 'utf8'); + const repoId = `sdk-refpol-ts-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + + const GitStorage = await loadGitStorage(); + patchGitStorage(GitStorage); + + const store = new GitStorage({ + name: issuer, + key, + apiBaseUrl, + storageBaseUrl, + }); + + console.log(`▶ TypeScript ref-policy live test (repo=${repoId})`); + + const repo = await store.createRepo({ id: repoId, defaultBranch: 'main' }); + console.log(' ✓ repo created'); + + const openUrl = await repo.getRemoteURL({ + permissions: ['git:read', 'git:write'], + ttl: 1800, + }); + const restrictedUrl = await repo.getRemoteURL({ + permissions: ['git:read', 'git:write'], + ttl: 600, + refs: [{ pattern: 'refs/heads/main', ops: ['no-push'] }], + }); + + const openToken = tokenFromRemoteUrl(openUrl); + const restrictedToken = tokenFromRemoteUrl(restrictedUrl); + const refsClaim = decodeJwtPayload(restrictedToken).refs; + const expected = [['refs/heads/main', ['no-push']]]; + if (JSON.stringify(refsClaim) !== JSON.stringify(expected)) { + console.error(`FAIL: unexpected refs claim: ${JSON.stringify(refsClaim)}`); + process.exit(1); + } + console.log(` ✓ JWT refs claim: ${JSON.stringify(refsClaim)}`); + + const work = mkdtempSync(path.join(os.tmpdir(), 'refpol-ts-')); + git(['init', '-b', 'main'], { cwd: work }); + git(['config', 'user.email', 'refpol@pierre.invalid'], { cwd: work }); + git(['config', 'user.name', 'RefPol Live'], { cwd: work }); + git(['config', 'commit.gpgsign', 'false'], { cwd: work }); + writeFileSync(path.join(work, 'README.md'), 'hello\n'); + git(['add', 'README.md'], { cwd: work }); + git(['commit', '-m', 'initial'], { cwd: work }); + + git(['remote', 'add', 'origin', buildGitRemote(repo.id, openToken)], { cwd: work }); + git(['push', '-u', 'origin', 'main'], { cwd: work }); + console.log(' ✓ seeded main via open token'); + + git(['checkout', '-b', 'feature/allowed'], { cwd: work }); + writeFileSync(path.join(work, 'README.md'), 'hello\nfeature\n'); + git(['add', 'README.md'], { cwd: work }); + git(['commit', '-m', 'feature commit'], { cwd: work }); + + git(['remote', 'set-url', 'origin', buildGitRemote(repo.id, restrictedToken)], { + cwd: work, + }); + git(['push', '-u', 'origin', 'feature/allowed'], { cwd: work }); + console.log(' ✓ feature branch push allowed'); + + git(['checkout', 'main'], { cwd: work }); + writeFileSync(path.join(work, 'README.md'), 'hello\nblocked\n'); + git(['add', 'README.md'], { cwd: work }); + git(['commit', '-m', 'main blocked attempt'], { cwd: work }); + const mainPush = gitAllowFail(['push', 'origin', 'main'], { cwd: work }); + if (mainPush.code === 0) { + console.error('FAIL: push to main should be denied by no-push policy'); + process.exit(1); + } + if (!mainPush.output.includes('denied by policy')) { + console.error( + `FAIL: expected 'denied by policy' in push output:\n${mainPush.output}` + ); + process.exit(1); + } + console.log(' ✓ main push denied by policy'); + + try { + await store.deleteRepo({ id: repoId }); + console.log(' ✓ repo deleted'); + } catch (err) { + console.log(` (cleanup warning: ${err.message})`); + } + + console.log('✅ TypeScript ref-policy live test passed'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 8503bbb..f5f8b69 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -44,7 +44,11 @@ Every request requires a JWT signed with your private key. Tokens are per-reposi "sub": "@pierre/storage", // Subject (the SDKs set this to the package name) "repo": "team/project", // Repository the token grants access to "scopes": ["git:read", "git:write"], - "ops": ["no-force-push"], // Optional policy operations (see below) + "ops": ["no-force-push"], // Optional repo-wide policy ops (legacy; see below) + "refs": [ // Optional per-ref policy rules (first match wins) + ["refs/heads/main", ["no-push"]], + ["refs/heads/feature/*", ["no-force-push"]] + ], "iat": 1723453189, "exp": 1723456789 } @@ -52,18 +56,55 @@ Every request requires a JWT signed with your private key. Tokens are per-reposi JWT header: `{ "alg": "ES256", "typ": "JWT" }` (RS256 and EdDSA also supported) -### Policy Operations (`ops`) +### Policy Operations -The optional `ops` claim narrows what a token may do at the protocol layer. The -SDKs expose the canonical constant for force-push prevention: +| Op string | SDK constant | Effect | +|------------------|------------------------------------------------------------------------------|-------------------------------------------------------| +| `no-force-push` | TS `OP_NO_FORCE_PUSH` / Py `OP_NO_FORCE_PUSH` / Go `storage.OpNoForcePush` | Rejects force pushes / non-fast-forward ref updates. | +| `no-push` | TS `OP_NO_PUSH` / Py `OP_NO_PUSH` / Go `storage.OpNoPush` | Rejects any push to matching refs. | -| Op string | SDK constant | Effect | -|------------------|---------------------------------------------------------------------------|-------------------------------------------------------| -| `no-force-push` | TS `OP_NO_FORCE_PUSH` / Py `OP_NO_FORCE_PUSH` / Go `storage.OpNoForcePush` | Rejects force pushes / non-fast-forward ref updates. | +#### Repo-wide `ops` (legacy) -Pass via the SDK `getRemoteURL` / `getEphemeralRemoteURL` / `getImportRemoteURL` -helpers (`{ ops: [OP_NO_FORCE_PUSH] }`) or include the `ops` array directly when -minting a JWT manually. +The optional top-level `ops` claim applies to every ref. On verify it is folded +into a trailing `*` rule when no explicit catch-all exists. + +Pass via `getRemoteURL` / `getEphemeralRemoteURL` / `getImportRemoteURL` +(`{ ops: [OP_NO_FORCE_PUSH] }`) or include `ops` directly when minting manually. + +#### Per-ref `refs` (preferred) + +The optional `refs` claim is an **ordered** array of `[pattern, [ops...]]` tuples. +Rules are evaluated in declaration order; the first pattern that matches the ref +wins. Patterns may be fully-qualified refs (`refs/heads/main`), prefix globs +(`refs/heads/feature/*`, `refs/tags/*`), or `*` for every ref. Short branch names +like `main` are normalized to `refs/heads/main` on verify. + +```typescript +await repo.getRemoteURL({ + refs: [ + { pattern: 'refs/heads/main', ops: [OP_NO_PUSH] }, + { pattern: '*', ops: [OP_NO_FORCE_PUSH] }, + ], +}); +``` + +```python +await repo.get_remote_url( + refs=[ + {"pattern": "refs/heads/main", "ops": [OP_NO_PUSH]}, + {"pattern": "*", "ops": [OP_NO_FORCE_PUSH]}, + ], +) +``` + +```go +repo.RemoteURL(ctx, storage.RemoteURLOptions{ + Refs: storage.RefPolicies{ + {Pattern: "refs/heads/main", Ops: storage.Ops{storage.OpNoPush}}, + {Pattern: "*", Ops: storage.Ops{storage.OpNoForcePush}}, + }, +}) +``` ### Permission Scopes @@ -799,8 +840,9 @@ safe_remote = await repo.get_remote_url( ``` The same `ops` array also works on `getEphemeralRemoteURL` and -`getImportRemoteURL`. When minting JWTs by hand, add `"ops": ["no-force-push"]` -to the payload before signing. +`getImportRemoteURL`. For per-ref rules, pass `refs` instead (see Policy +Operations above). When minting JWTs by hand, add `"ops"` or `"refs"` to the +payload before signing. ## PROCEDURE 8: Rollback a Branch @@ -897,5 +939,5 @@ git push origin feature-branch | Pagination | Cursor-based. Pass `next_cursor` as `cursor` param. Stop when `has_more: false`. | | Blob data encoding | Always base64. Max 4 MiB per chunk. Use multiple chunks for large files. | | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. | -| Policy ops (`ops`) | JWT-level guards. `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates. | +| Policy ops | JWT-level guards via `ops` (repo-wide) or `refs` (per-ref, first match wins). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates; `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. | | Merge endpoint | `POST /repos/merge`; strategies: `merge`, `ff_only`, `ff_prefer`; optional `squash` (not with `ff_only`). 409 on conflict. | From df4e54c84ab4596c64b4c9952b02b1bf4edea2c0 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 22 May 2026 17:18:14 -0400 Subject: [PATCH 2/9] drop per-op `ops` field; ref policies on REST calls take `refs` only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-op REST options never had an `ops` field on main; my earlier commit added one alongside `refs`. Remove it — `ops` belongs on the URL minting path (RemoteURLOptions only), and per-op REST calls take `refs` only. --- packages/code-storage-go/commit.go | 2 +- packages/code-storage-go/diff_commit.go | 2 +- packages/code-storage-go/repo.go | 24 +++++------ packages/code-storage-go/types.go | 27 +------------ .../pierre_storage/repo.py | 40 +++++-------------- packages/code-storage-typescript/src/index.ts | 16 +------- packages/code-storage-typescript/src/types.ts | 6 +-- 7 files changed, 31 insertions(+), 86 deletions(-) diff --git a/packages/code-storage-go/commit.go b/packages/code-storage-go/commit.go index 1b856ce..2c81f3b 100644 --- a/packages/code-storage-go/commit.go +++ b/packages/code-storage-go/commit.go @@ -183,7 +183,7 @@ func (b *CommitBuilder) Send(ctx context.Context) (CommitResult, error) { } ttl := resolveCommitTTL(b.options.InvocationOptions, defaultTokenTTL) - jwtToken, err := b.client.generateJWT(b.repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: b.options.Ops, Refs: b.options.Refs}) + jwtToken, err := b.client.generateJWT(b.repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: b.options.Refs}) if err != nil { return CommitResult{}, err } diff --git a/packages/code-storage-go/diff_commit.go b/packages/code-storage-go/diff_commit.go index a5a3149..b8c4122 100644 --- a/packages/code-storage-go/diff_commit.go +++ b/packages/code-storage-go/diff_commit.go @@ -67,7 +67,7 @@ func (d *diffCommitExecutor) send(ctx context.Context, repoID string) (CommitRes } ttl := resolveCommitTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := d.client.generateJWT(repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := d.client.generateJWT(repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return CommitResult{}, err } diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 9aad721..292d621 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -536,12 +536,12 @@ func (r *Repo) GetNote(ctx context.Context, options GetNoteOptions) (GetNoteResu // CreateNote adds a git note. func (r *Repo) CreateNote(ctx context.Context, options CreateNoteOptions) (NoteWriteResult, error) { - return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Ops, options.Refs) + return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Refs) } // AppendNote appends to a git note. func (r *Repo) AppendNote(ctx context.Context, options AppendNoteOptions) (NoteWriteResult, error) { - return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Ops, options.Refs) + return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Refs) } // DeleteNote deletes a git note. @@ -552,7 +552,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return NoteWriteResult{}, err } @@ -592,7 +592,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW return result, nil } -func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor, ops Ops, refs RefPolicies) (NoteWriteResult, error) { +func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor, refs RefPolicies) (NoteWriteResult, error) { sha = strings.TrimSpace(sha) if sha == "" { return NoteWriteResult{}, errors.New("note sha is required") @@ -604,7 +604,7 @@ func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, acti } ttl := resolveInvocationTTL(invocation, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: ops, Refs: refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: refs}) if err != nil { return NoteWriteResult{}, err } @@ -862,7 +862,7 @@ func (r *Repo) Grep(ctx context.Context, options GrepOptions) (GrepResult, error // PullUpstream triggers a pull-upstream operation. func (r *Repo) PullUpstream(ctx context.Context, options PullUpstreamOptions) error { ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return err } @@ -897,7 +897,7 @@ func (r *Repo) CreateBranch(ctx context.Context, options CreateBranchOptions) (C } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return CreateBranchResult{}, err } @@ -944,7 +944,7 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return DeleteBranchResult{}, err } @@ -1020,7 +1020,7 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return MergeResult{}, err } @@ -1081,7 +1081,7 @@ func (r *Repo) CreateTag(ctx context.Context, options CreateTagOptions) (CreateT } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return CreateTagResult{}, err } @@ -1116,7 +1116,7 @@ func (r *Repo) DeleteTag(ctx context.Context, options DeleteTagOptions) (DeleteT } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return DeleteTagResult{}, err } @@ -1159,7 +1159,7 @@ func (r *Repo) RestoreCommit(ctx context.Context, options RestoreCommitOptions) } ttl := resolveCommitTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Ops: options.Ops, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return RestoreCommitResult{}, err } diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index f66f132..208e21c 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -54,8 +54,7 @@ type RefPolicies []RefPolicy type RemoteURLOptions struct { Permissions []Permission TTL time.Duration - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops + Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -245,8 +244,6 @@ type ArchiveOptions struct { type PullUpstreamOptions struct { InvocationOptions Ref string - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -326,8 +323,6 @@ type CreateBranchOptions struct { TargetBranch string BaseIsEphemeral bool TargetIsEphemeral bool - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -345,8 +340,6 @@ type DeleteBranchOptions struct { InvocationOptions Name string Ephemeral *bool - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -382,8 +375,6 @@ type MergeOptions struct { AllowUnrelatedHistories bool // Squash is incompatible with MergeStrategyFFOnly. Squash bool - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -451,8 +442,6 @@ type CreateTagOptions struct { InvocationOptions Name string Target string - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -468,8 +457,6 @@ type CreateTagResult struct { type DeleteTagOptions struct { InvocationOptions Name string - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -581,8 +568,6 @@ type CreateNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -594,8 +579,6 @@ type AppendNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -606,8 +589,6 @@ type DeleteNoteOptions struct { SHA string ExpectedRefSHA string Author *NoteAuthor - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -848,8 +829,6 @@ type CommitOptions struct { EphemeralBase bool Author CommitSignature Committer *CommitSignature - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -866,8 +845,6 @@ type CommitFromDiffOptions struct { EphemeralBase bool Author CommitSignature Committer *CommitSignature - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } @@ -881,8 +858,6 @@ type RestoreCommitOptions struct { ExpectedHeadSHA string Author CommitSignature Committer *CommitSignature - // Ops applies repo-wide (legacy; folded into a catch-all "*" rule on verify). - Ops Ops // Refs is evaluated in declaration order; the first matching rule wins. Refs RefPolicies } diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index ccd17e1..edc19c8 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -63,13 +63,10 @@ def _build_jwt_options( permissions: List[str], ttl: int, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> Dict[str, Any]: """Assemble the JWT options dict, attaching ref policies when supplied.""" options: Dict[str, Any] = {"permissions": permissions, "ttl": ttl} - if ops: - options["ops"] = ops if refs: options["refs"] = refs return options @@ -592,7 +589,6 @@ async def create_branch( base_is_ephemeral: bool = False, target_is_ephemeral: bool = False, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> CreateBranchResult: """Create or promote a branch. @@ -625,7 +621,7 @@ async def create_branch( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, ops, refs), + _build_jwt_options(["git:write"], ttl_value, refs), ) payload: Dict[str, Any] = { @@ -684,7 +680,6 @@ async def delete_branch( name: str, ephemeral: Optional[bool] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> DeleteBranchResult: """Delete a branch. @@ -701,7 +696,7 @@ async def delete_branch( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, ops, refs), + _build_jwt_options(["git:write"], ttl_value, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/branches" @@ -759,7 +754,6 @@ async def merge( allow_unrelated_histories: Optional[bool] = None, squash: Optional[bool] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> MergeBranchesResult: """Merge a source branch into a target branch.""" @@ -813,7 +807,7 @@ async def merge( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, ops, refs), + _build_jwt_options(["git:write"], ttl_value, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/merge" @@ -921,7 +915,6 @@ async def create_tag( name: str, target: str, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> CreateTagResult: """Create a tag.""" @@ -938,7 +931,7 @@ async def create_tag( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, ops, refs), + _build_jwt_options(["git:write"], ttl_value, refs), ) payload = {"name": name_clean, "target": target_clean} @@ -982,7 +975,6 @@ async def delete_tag( *, name: str, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> DeleteTagResult: """Delete a tag.""" @@ -995,7 +987,7 @@ async def delete_tag( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:read", "git:write"], ttl_value, ops, refs), + _build_jwt_options(["git:read", "git:write"], ttl_value, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/tags" @@ -1316,7 +1308,6 @@ async def create_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> NoteWriteResult: """Create a git note.""" @@ -1328,7 +1319,6 @@ async def create_note( expected_ref_sha=expected_ref_sha, author=author, ttl=ttl, - ops=ops, refs=refs, ) @@ -1340,7 +1330,6 @@ async def append_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> NoteWriteResult: """Append to a git note.""" @@ -1352,7 +1341,6 @@ async def append_note( expected_ref_sha=expected_ref_sha, author=author, ttl=ttl, - ops=ops, refs=refs, ) @@ -1363,7 +1351,6 @@ async def delete_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> NoteWriteResult: """Delete a git note.""" @@ -1372,7 +1359,7 @@ async def delete_note( raise ValueError("delete_note sha is required") ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ops, refs)) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) payload: Dict[str, Any] = {"sha": sha_clean} if expected_ref_sha and expected_ref_sha.strip(): @@ -1683,7 +1670,6 @@ async def pull_upstream( *, ref: Optional[str] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> None: """Pull from upstream repository. @@ -1698,7 +1684,7 @@ async def pull_upstream( ApiError: If pull fails """ ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ops, refs)) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) body = {} if ref: @@ -1732,7 +1718,6 @@ async def restore_commit( expected_head_sha: Optional[str] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> RestoreCommitResult: """Restore a previous commit. @@ -1769,7 +1754,7 @@ async def restore_commit( raise ValueError("restoreCommit author name and email are required") ttl = ttl or resolve_commit_ttl_seconds(None) - jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ops, refs)) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) metadata: Dict[str, Any] = { "target_branch": target_branch, @@ -1873,7 +1858,6 @@ def create_commit( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> CommitBuilder: """Create a new commit builder. @@ -1914,7 +1898,7 @@ def create_commit( def get_auth_token() -> str: return self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl, ops, refs), + _build_jwt_options(["git:write"], ttl, refs), ) return CommitBuilderImpl( @@ -1937,7 +1921,6 @@ async def create_commit_from_diff( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> CommitResult: """Create a commit by applying a unified diff.""" @@ -1966,7 +1949,7 @@ async def create_commit_from_diff( def get_auth_token() -> str: return self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, ops, refs), + _build_jwt_options(["git:write"], ttl_value, refs), ) return await send_diff_commit_request( @@ -1987,7 +1970,6 @@ async def _write_note( expected_ref_sha: Optional[str], author: Optional[CommitSignature], ttl: Optional[int], - ops: Optional[List[str]] = None, refs: Optional[Refs] = None, ) -> NoteWriteResult: sha_clean = sha.strip() @@ -1999,7 +1981,7 @@ async def _write_note( raise ValueError(f"{action_label} note is required") ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ops, refs)) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) payload: Dict[str, Any] = { "sha": sha_clean, diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 6d76ea1..7caa3fd 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -1101,7 +1101,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1148,7 +1147,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1190,7 +1188,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1400,7 +1397,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1441,7 +1437,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1484,7 +1479,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1527,7 +1521,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1605,7 +1598,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1630,7 +1622,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:read', 'git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1671,7 +1662,6 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, refs: options.refs, }); @@ -1750,8 +1740,7 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, - refs: options.refs, + refs: options.refs, }); return createCommitBuilder({ @@ -1778,8 +1767,7 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, - ops: options.ops, - refs: options.refs, + refs: options.refs, }); return sendCommitFromDiff({ diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index bddea89..227f825 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -64,10 +64,8 @@ export interface RefPolicy { /** Ordered per-ref policy rules for the JWT `refs` claim. */ export type RefPolicies = RefPolicy[]; -/** Optional ref policies that can be attached to a minted JWT for any ref-mutating call. */ +/** Optional per-ref policies that can be attached to a minted JWT for any ref-mutating call. */ export interface PolicyOptions { - /** Repo-wide policy ops (legacy; folded into a catch-all `*` rule on verify). */ - ops?: Ops; /** Per-ref policy rules evaluated in declaration order (first match wins). */ refs?: RefPolicies; } @@ -75,6 +73,8 @@ export interface PolicyOptions { export interface GetRemoteURLOptions extends PolicyOptions { permissions?: ('git:write' | 'git:read' | 'repo:write' | 'org:read')[]; ttl?: number; + /** Repo-wide policy ops (legacy; folded into a catch-all `*` rule on verify). */ + ops?: Ops; } export interface Repo { From d19009dcb875a51de341fc874586430dcb9e721f Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 22 May 2026 17:37:26 -0400 Subject: [PATCH 3/9] ref-policies-live.mjs: import encodeRefsClaim from dist, not src .ts The live test's `import ... from '../src/jwt_claims.ts'` failed under plain node (no TS loader). Take encodeRefsClaim from the dist barrel that loadGitStorage already imports, so the script runs with `node`. --- .../code-storage-typescript/tests/ref-policies-live.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/code-storage-typescript/tests/ref-policies-live.mjs b/packages/code-storage-typescript/tests/ref-policies-live.mjs index 7c5d4e7..4c48b1c 100644 --- a/packages/code-storage-typescript/tests/ref-policies-live.mjs +++ b/packages/code-storage-typescript/tests/ref-policies-live.mjs @@ -12,7 +12,6 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { encodeRefsClaim } from '../src/jwt_claims.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -116,6 +115,8 @@ function buildGitRemote(repoId, token) { return `${base}/${repoId}.git`.replace('://', `://t:${token}@`); } +let encodeRefsClaim; + async function loadGitStorage() { const candidates = [ path.resolve(__dirname, '../dist/index.js'), @@ -124,11 +125,13 @@ async function loadGitStorage() { for (const candidate of candidates) { if (existsSync(candidate)) { const mod = await import(pathToFileURL(candidate).href); + encodeRefsClaim = mod.encodeRefsClaim; return mod.GitStorage ?? mod.default; } } - const mod = await import('../src/index.ts'); - return mod.GitStorage; + throw new Error( + 'GitStorage dist build not found. Run "pnpm --filter @pierre/storage build" first.' + ); } async function main() { From 38e94848f5b34339d1710ff7337af47ae3fb8d1a Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 26 May 2026 11:14:29 -0400 Subject: [PATCH 4/9] skills: stop writing new code with legacy `ops`, lead with `refs` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder the policy documentation so the per-ref `refs` claim is presented as the way to add branch protection. The repo-wide `ops` claim is now labeled "legacy — do not use in new code" and explicitly redirects to the equivalent `refs: [{ pattern: '*', ops: [...] }]` form. The example procedure (Mint a Force-Push-Prevented Remote URL) and the JWT payload example are updated to use `refs` so anyone copying from the skill writes the modern form. --- skills/code-storage/SKILL.md | 41 +++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index f5f8b69..84a2d44 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -44,7 +44,6 @@ Every request requires a JWT signed with your private key. Tokens are per-reposi "sub": "@pierre/storage", // Subject (the SDKs set this to the package name) "repo": "team/project", // Repository the token grants access to "scopes": ["git:read", "git:write"], - "ops": ["no-force-push"], // Optional repo-wide policy ops (legacy; see below) "refs": [ // Optional per-ref policy rules (first match wins) ["refs/heads/main", ["no-push"]], ["refs/heads/feature/*", ["no-force-push"]] @@ -63,21 +62,21 @@ JWT header: `{ "alg": "ES256", "typ": "JWT" }` (RS256 and EdDSA also supported) | `no-force-push` | TS `OP_NO_FORCE_PUSH` / Py `OP_NO_FORCE_PUSH` / Go `storage.OpNoForcePush` | Rejects force pushes / non-fast-forward ref updates. | | `no-push` | TS `OP_NO_PUSH` / Py `OP_NO_PUSH` / Go `storage.OpNoPush` | Rejects any push to matching refs. | -#### Repo-wide `ops` (legacy) +#### Per-ref `refs` (preferred — use this for new code) -The optional top-level `ops` claim applies to every ref. On verify it is folded -into a trailing `*` rule when no explicit catch-all exists. - -Pass via `getRemoteURL` / `getEphemeralRemoteURL` / `getImportRemoteURL` -(`{ ops: [OP_NO_FORCE_PUSH] }`) or include `ops` directly when minting manually. - -#### Per-ref `refs` (preferred) - -The optional `refs` claim is an **ordered** array of `[pattern, [ops...]]` tuples. +The `refs` claim is an **ordered** array of `[pattern, [ops...]]` tuples. Rules are evaluated in declaration order; the first pattern that matches the ref wins. Patterns may be fully-qualified refs (`refs/heads/main`), prefix globs (`refs/heads/feature/*`, `refs/tags/*`), or `*` for every ref. Short branch names -like `main` are normalized to `refs/heads/main` on verify. +like `main` are normalized to `refs/heads/main` on verify. `refs` is accepted by +every URL-minting method and every ref-mutating REST method. + +#### Repo-wide `ops` (legacy — do not use in new code) + +The optional top-level `ops` claim applies to every ref. On verify it is folded +into a trailing `*` rule when no explicit catch-all exists. Only available on +the URL-minting methods (`getRemoteURL` / `getEphemeralRemoteURL` / +`getImportRemoteURL`). Use `refs: [{ pattern: '*', ops: [...] }]` instead. ```typescript await repo.getRemoteURL({ @@ -822,7 +821,7 @@ const repo = store.repo({ id: 'team/project' }); const safeRemote = await repo.getRemoteURL({ permissions: ['git:write'], ttl: 3600, - ops: [OP_NO_FORCE_PUSH], + refs: [{ pattern: '*', ops: [OP_NO_FORCE_PUSH] }], }); // git push to safeRemote — non-fast-forward updates are rejected. ``` @@ -835,14 +834,18 @@ repo = store.repo(id="team/project") safe_remote = await repo.get_remote_url( permissions=["git:write"], ttl=3600, - ops=[OP_NO_FORCE_PUSH], + refs=[{"pattern": "*", "ops": [OP_NO_FORCE_PUSH]}], ) ``` -The same `ops` array also works on `getEphemeralRemoteURL` and -`getImportRemoteURL`. For per-ref rules, pass `refs` instead (see Policy -Operations above). When minting JWTs by hand, add `"ops"` or `"refs"` to the -payload before signing. +`refs` is also accepted by `getEphemeralRemoteURL`, `getImportRemoteURL`, and +every ref-mutating REST method (`createBranch`, `merge`, `createCommit`, notes, +tags, etc.) — define the policy once and reuse it. When minting JWTs by hand, +add `"refs"` to the payload before signing. + +> The legacy top-level `ops` claim is still accepted on URL-minting methods for +> backwards compatibility (folded into a catch-all `*` rule on verify), but new +> code should use `refs` everywhere. ## PROCEDURE 8: Rollback a Branch @@ -939,5 +942,5 @@ git push origin feature-branch | Pagination | Cursor-based. Pass `next_cursor` as `cursor` param. Stop when `has_more: false`. | | Blob data encoding | Always base64. Max 4 MiB per chunk. Use multiple chunks for large files. | | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. | -| Policy ops | JWT-level guards via `ops` (repo-wide) or `refs` (per-ref, first match wins). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates; `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. | +| Policy ops | JWT-level guards via `refs` (per-ref, first match wins; preferred). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates; `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. Top-level `ops` is a legacy alias on URL-minting methods only. | | Merge endpoint | `POST /repos/merge`; strategies: `merge`, `ff_only`, `ff_prefer`; optional `squash` (not with `ff_only`). 409 on conflict. | From 9579d55153c95b1c7f0a8e95c43cd728529a1e85 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 26 May 2026 11:19:44 -0400 Subject: [PATCH 5/9] =?UTF-8?q?skills:=20clarify=20legacy=20ops=20folding?= =?UTF-8?q?=20=E2=80=94=20merges=20into=20existing=20*=20rule=20when=20pre?= =?UTF-8?q?sent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/code-storage/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 84a2d44..5fddb3c 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -74,8 +74,9 @@ every URL-minting method and every ref-mutating REST method. #### Repo-wide `ops` (legacy — do not use in new code) The optional top-level `ops` claim applies to every ref. On verify it is folded -into a trailing `*` rule when no explicit catch-all exists. Only available on -the URL-minting methods (`getRemoteURL` / `getEphemeralRemoteURL` / +into the catch-all `*` rule — merged into an existing `*` entry in `refs` if +one is present, or appended as a new trailing `*` rule otherwise. Only available +on the URL-minting methods (`getRemoteURL` / `getEphemeralRemoteURL` / `getImportRemoteURL`). Use `refs: [{ pattern: '*', ops: [...] }]` instead. ```typescript From 0ce2f2924ee2cbf8f38d426f22cb8a8252ead68d Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 26 May 2026 11:37:54 -0400 Subject: [PATCH 6/9] rename SDK option `refs` to `refPolicies` for readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-word `refs` SDK option name was confusing next to the JWT `refs` claim and the various `ref`/`refs/heads/...` strings in this code. Rename the SDK-surface field so it reads as "ref policies": - TypeScript: `PolicyOptions.refs` → `refPolicies` (on `GetRemoteURLOptions` and every option interface that extended `PolicyOptions`). Type alias `RefPolicies` kept. - Go: every struct field `Refs RefPolicies` → `RefPolicies RefPolicyList`. Type renamed from `RefPolicies` to `RefPolicyList` to avoid colliding with the new field name. - Python: every kwarg `refs: Optional[Refs]` → `ref_policies`. Type alias `Refs` kept. `_build_jwt_options` helper param renamed to match. The wire JWT claim remains `"refs"` on all three SDKs (verified in `encodeRefsClaim` / `encode_refs_claim` and the JWT-mint paths). No gateway-side change is needed. Also tidies em-dashes and clause-joining semicolons in deprecation comments and docstrings touched on this branch, and updates the code examples in `skills/code-storage/SKILL.md` to the new field name. --- packages/code-storage-go/client.go | 4 +- packages/code-storage-go/commit.go | 2 +- packages/code-storage-go/diff_commit.go | 2 +- packages/code-storage-go/jwt_claims.go | 2 +- packages/code-storage-go/jwt_claims_test.go | 2 +- packages/code-storage-go/repo.go | 24 ++-- packages/code-storage-go/repo_test.go | 2 +- packages/code-storage-go/types.go | 65 +++++----- .../pierre_storage/repo.py | 111 +++++++++++------- .../pierre_storage/types.py | 6 +- .../tests/ref_policies_live.py | 2 +- packages/code-storage-typescript/src/index.ts | 28 ++--- packages/code-storage-typescript/src/types.ts | 12 +- .../tests/index.test.ts | 4 +- .../tests/ref-policies-live.mjs | 4 +- skills/code-storage/SKILL.md | 65 +++++----- 16 files changed, 188 insertions(+), 147 deletions(-) diff --git a/packages/code-storage-go/client.go b/packages/code-storage-go/client.go index 896e452..3d5aa7e 100644 --- a/packages/code-storage-go/client.go +++ b/packages/code-storage-go/client.go @@ -470,8 +470,8 @@ func (c *Client) generateJWT(repoID string, options RemoteURLOptions) (string, e "iat": issuedAt.Unix(), "exp": issuedAt.Add(ttl).Unix(), } - if len(options.Refs) > 0 { - claims["refs"] = encodeRefsClaim(options.Refs) + if len(options.RefPolicies) > 0 { + claims["refs"] = encodeRefsClaim(options.RefPolicies) } if len(options.Ops) > 0 { claims["ops"] = options.Ops diff --git a/packages/code-storage-go/commit.go b/packages/code-storage-go/commit.go index 2c81f3b..c2477c5 100644 --- a/packages/code-storage-go/commit.go +++ b/packages/code-storage-go/commit.go @@ -183,7 +183,7 @@ func (b *CommitBuilder) Send(ctx context.Context) (CommitResult, error) { } ttl := resolveCommitTTL(b.options.InvocationOptions, defaultTokenTTL) - jwtToken, err := b.client.generateJWT(b.repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: b.options.Refs}) + jwtToken, err := b.client.generateJWT(b.repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: b.options.RefPolicies}) if err != nil { return CommitResult{}, err } diff --git a/packages/code-storage-go/diff_commit.go b/packages/code-storage-go/diff_commit.go index b8c4122..5e8f90c 100644 --- a/packages/code-storage-go/diff_commit.go +++ b/packages/code-storage-go/diff_commit.go @@ -67,7 +67,7 @@ func (d *diffCommitExecutor) send(ctx context.Context, repoID string) (CommitRes } ttl := resolveCommitTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := d.client.generateJWT(repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := d.client.generateJWT(repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return CommitResult{}, err } diff --git a/packages/code-storage-go/jwt_claims.go b/packages/code-storage-go/jwt_claims.go index a45e4a1..702b35e 100644 --- a/packages/code-storage-go/jwt_claims.go +++ b/packages/code-storage-go/jwt_claims.go @@ -1,7 +1,7 @@ package storage // encodeRefsClaim builds the JWT `refs` claim as an array of [pattern, ops] tuples. -func encodeRefsClaim(refs RefPolicies) []any { +func encodeRefsClaim(refs RefPolicyList) []any { out := make([]any, len(refs)) for i, rule := range refs { opList := []string(rule.Ops) diff --git a/packages/code-storage-go/jwt_claims_test.go b/packages/code-storage-go/jwt_claims_test.go index 98f8bfe..c10668e 100644 --- a/packages/code-storage-go/jwt_claims_test.go +++ b/packages/code-storage-go/jwt_claims_test.go @@ -3,7 +3,7 @@ package storage import "testing" func TestEncodeRefsClaim(t *testing.T) { - encoded := encodeRefsClaim(RefPolicies{ + encoded := encodeRefsClaim(RefPolicyList{ {Pattern: "refs/heads/main", Ops: Ops{OpNoPush}}, {Pattern: "refs/heads/release/*", Ops: nil}, {Pattern: "*", Ops: Ops{OpNoForcePush}}, diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 292d621..76abffe 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -536,12 +536,12 @@ func (r *Repo) GetNote(ctx context.Context, options GetNoteOptions) (GetNoteResu // CreateNote adds a git note. func (r *Repo) CreateNote(ctx context.Context, options CreateNoteOptions) (NoteWriteResult, error) { - return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Refs) + return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.RefPolicies) } // AppendNote appends to a git note. func (r *Repo) AppendNote(ctx context.Context, options AppendNoteOptions) (NoteWriteResult, error) { - return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Refs) + return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.RefPolicies) } // DeleteNote deletes a git note. @@ -552,7 +552,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return NoteWriteResult{}, err } @@ -592,7 +592,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW return result, nil } -func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor, refs RefPolicies) (NoteWriteResult, error) { +func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor, refPolicies RefPolicyList) (NoteWriteResult, error) { sha = strings.TrimSpace(sha) if sha == "" { return NoteWriteResult{}, errors.New("note sha is required") @@ -604,7 +604,7 @@ func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, acti } ttl := resolveInvocationTTL(invocation, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: refPolicies}) if err != nil { return NoteWriteResult{}, err } @@ -862,7 +862,7 @@ func (r *Repo) Grep(ctx context.Context, options GrepOptions) (GrepResult, error // PullUpstream triggers a pull-upstream operation. func (r *Repo) PullUpstream(ctx context.Context, options PullUpstreamOptions) error { ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return err } @@ -897,7 +897,7 @@ func (r *Repo) CreateBranch(ctx context.Context, options CreateBranchOptions) (C } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return CreateBranchResult{}, err } @@ -944,7 +944,7 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return DeleteBranchResult{}, err } @@ -1020,7 +1020,7 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return MergeResult{}, err } @@ -1081,7 +1081,7 @@ func (r *Repo) CreateTag(ctx context.Context, options CreateTagOptions) (CreateT } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return CreateTagResult{}, err } @@ -1116,7 +1116,7 @@ func (r *Repo) DeleteTag(ctx context.Context, options DeleteTagOptions) (DeleteT } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return DeleteTagResult{}, err } @@ -1159,7 +1159,7 @@ func (r *Repo) RestoreCommit(ctx context.Context, options RestoreCommitOptions) } ttl := resolveCommitTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, RefPolicies: options.RefPolicies}) if err != nil { return RestoreCommitResult{}, err } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index c845b5e..d9051df 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -113,7 +113,7 @@ func TestRemoteURLRefs(t *testing.T) { t.Run("includes refs in JWT when provided", func(t *testing.T) { remote, err := repo.RemoteURL(nil, RemoteURLOptions{ - Refs: RefPolicies{ + RefPolicies: RefPolicyList{ {Pattern: "refs/heads/main", Ops: Ops{OpNoPush}}, {Pattern: "*", Ops: Ops{OpNoForcePush}}, }, diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 208e21c..2be8f91 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -47,16 +47,23 @@ type RefPolicy struct { Ops Ops } -// RefPolicies is an ordered list of per-ref policy rules for the JWT `refs` claim. -type RefPolicies []RefPolicy +// RefPolicyList is an ordered list of per-ref policy rules for the JWT `refs` claim. +type RefPolicyList []RefPolicy // RemoteURLOptions configure token generation for remote URLs. type RemoteURLOptions struct { Permissions []Permission TTL time.Duration - Ops Ops - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // Ops is a repo-wide policy ops list. + // + // Deprecated: Use RefPolicies instead. On verify the gateway folds Ops into + // the catch-all "*" rule. It is merged into an existing "*" entry in + // RefPolicies when one is present, or appended as a new trailing "*" rule + // otherwise. Prefer RefPolicies: RefPolicyList{{Pattern: "*", Ops: ...}} + // for new code. + Ops Ops + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // InvocationOptions holds common request options. @@ -244,8 +251,8 @@ type ArchiveOptions struct { type PullUpstreamOptions struct { InvocationOptions Ref string - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // ListFilesOptions configures list files. @@ -323,8 +330,8 @@ type CreateBranchOptions struct { TargetBranch string BaseIsEphemeral bool TargetIsEphemeral bool - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // CreateBranchResult describes branch creation result. @@ -340,8 +347,8 @@ type DeleteBranchOptions struct { InvocationOptions Name string Ephemeral *bool - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // DeleteBranchResult describes branch deletion result. @@ -375,8 +382,8 @@ type MergeOptions struct { AllowUnrelatedHistories bool // Squash is incompatible with MergeStrategyFFOnly. Squash bool - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // MergeResultStatus describes a merge operation outcome. @@ -442,8 +449,8 @@ type CreateTagOptions struct { InvocationOptions Name string Target string - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // CreateTagResult describes tag creation result. @@ -457,8 +464,8 @@ type CreateTagResult struct { type DeleteTagOptions struct { InvocationOptions Name string - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // DeleteTagResult describes tag deletion result. @@ -568,8 +575,8 @@ type CreateNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // AppendNoteOptions configures note append. @@ -579,8 +586,8 @@ type AppendNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // DeleteNoteOptions configures note delete. @@ -589,8 +596,8 @@ type DeleteNoteOptions struct { SHA string ExpectedRefSHA string Author *NoteAuthor - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // NoteWriteResult describes note write response. @@ -829,8 +836,8 @@ type CommitOptions struct { EphemeralBase bool Author CommitSignature Committer *CommitSignature - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // CommitFromDiffOptions configures diff commit. @@ -845,8 +852,8 @@ type CommitFromDiffOptions struct { EphemeralBase bool Author CommitSignature Committer *CommitSignature - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // RestoreCommitOptions configures restore commit. @@ -858,8 +865,8 @@ type RestoreCommitOptions struct { ExpectedHeadSHA string Author CommitSignature Committer *CommitSignature - // Refs is evaluated in declaration order; the first matching rule wins. - Refs RefPolicies + // RefPolicies is evaluated in declaration order. The first matching rule wins. + RefPolicies RefPolicyList } // RestoreCommitResult describes restore commit. diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index edc19c8..2a65687 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -63,12 +63,12 @@ def _build_jwt_options( permissions: List[str], ttl: int, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> Dict[str, Any]: """Assemble the JWT options dict, attaching ref policies when supplied.""" options: Dict[str, Any] = {"permissions": permissions, "ttl": ttl} - if refs: - options["refs"] = refs + if ref_policies: + options["refs"] = ref_policies return options @@ -221,19 +221,29 @@ async def get_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> str: """Get remote URL for Git operations. Args: permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds - ops: List of policy operations (e.g., ["no-force-push"]) - refs: Ordered per-ref policy rules (first match wins) + ops: Deprecated. Repo-wide policy operations. On verify the gateway + folds ``ops`` into the catch-all ``*`` rule. It is merged into + an existing ``*`` entry in ``ref_policies`` when one is present, + or appended as a new trailing ``*`` rule otherwise. Use + ``ref_policies=[{"pattern": "*", "ops": [...]}]`` instead. + ref_policies: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT """ + if ops is not None: + warnings.warn( + "ops is deprecated. Use ref_policies=[{'pattern': '*', 'ops': [...]}] instead.", + DeprecationWarning, + stacklevel=2, + ) options: Dict[str, Any] = {} if permissions is not None: options["permissions"] = permissions @@ -241,8 +251,8 @@ async def get_remote_url( options["ttl"] = ttl if ops is not None: options["ops"] = ops - if refs is not None: - options["refs"] = refs + if ref_policies is not None: + options["refs"] = ref_policies jwt_token = self.generate_jwt(self._id, options if options else None) url = f"https://t:{jwt_token}@{self.storage_base_url}/{self._id}.git" @@ -254,21 +264,28 @@ async def get_import_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> str: """Get import remote URL for Git operations. Args: permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds - ops: List of policy operations (e.g., ["no-force-push"]) - refs: Ordered per-ref policy rules (first match wins) + ops: Deprecated. See :meth:`get_remote_url` for details. Use + ``ref_policies=[{"pattern": "*", "ops": [...]}]`` instead. + ref_policies: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT pointing to import namespace """ + if ops is not None: + warnings.warn( + "ops is deprecated. Use ref_policies=[{'pattern': '*', 'ops': [...]}] instead.", + DeprecationWarning, + stacklevel=2, + ) url = await self.get_remote_url( - permissions=permissions, ttl=ttl, ops=ops, refs=refs + permissions=permissions, ttl=ttl, ops=ops, ref_policies=ref_policies ) return url.replace(".git", "+import.git") @@ -278,21 +295,28 @@ async def get_ephemeral_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> str: """Get ephemeral remote URL for Git operations. Args: permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds - ops: List of policy operations (e.g., ["no-force-push"]) - refs: Ordered per-ref policy rules (first match wins) + ops: Deprecated. See :meth:`get_remote_url` for details. Use + ``ref_policies=[{"pattern": "*", "ops": [...]}]`` instead. + ref_policies: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT pointing to ephemeral namespace """ + if ops is not None: + warnings.warn( + "ops is deprecated. Use ref_policies=[{'pattern': '*', 'ops': [...]}] instead.", + DeprecationWarning, + stacklevel=2, + ) url = await self.get_remote_url( - permissions=permissions, ttl=ttl, ops=ops, refs=refs + permissions=permissions, ttl=ttl, ops=ops, ref_policies=ref_policies ) return url.replace(".git", "+ephemeral.git") @@ -589,7 +613,7 @@ async def create_branch( base_is_ephemeral: bool = False, target_is_ephemeral: bool = False, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> CreateBranchResult: """Create or promote a branch. @@ -621,7 +645,7 @@ async def create_branch( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, refs), + _build_jwt_options(["git:write"], ttl_value, ref_policies), ) payload: Dict[str, Any] = { @@ -680,7 +704,7 @@ async def delete_branch( name: str, ephemeral: Optional[bool] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> DeleteBranchResult: """Delete a branch. @@ -696,7 +720,7 @@ async def delete_branch( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, refs), + _build_jwt_options(["git:write"], ttl_value, ref_policies), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/branches" @@ -754,7 +778,7 @@ async def merge( allow_unrelated_histories: Optional[bool] = None, squash: Optional[bool] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> MergeBranchesResult: """Merge a source branch into a target branch.""" source_branch_clean = source_branch.strip() @@ -807,7 +831,7 @@ async def merge( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, refs), + _build_jwt_options(["git:write"], ttl_value, ref_policies), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/merge" @@ -915,7 +939,7 @@ async def create_tag( name: str, target: str, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> CreateTagResult: """Create a tag.""" name_clean = name.strip() @@ -931,7 +955,7 @@ async def create_tag( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, refs), + _build_jwt_options(["git:write"], ttl_value, ref_policies), ) payload = {"name": name_clean, "target": target_clean} @@ -975,7 +999,7 @@ async def delete_tag( *, name: str, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> DeleteTagResult: """Delete a tag.""" name_clean = name.strip() @@ -987,7 +1011,7 @@ async def delete_tag( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - _build_jwt_options(["git:read", "git:write"], ttl_value, refs), + _build_jwt_options(["git:read", "git:write"], ttl_value, ref_policies), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/tags" @@ -1308,7 +1332,7 @@ async def create_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: """Create a git note.""" return await self._write_note( @@ -1319,7 +1343,7 @@ async def create_note( expected_ref_sha=expected_ref_sha, author=author, ttl=ttl, - refs=refs, + ref_policies=ref_policies, ) async def append_note( @@ -1330,7 +1354,7 @@ async def append_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: """Append to a git note.""" return await self._write_note( @@ -1341,7 +1365,7 @@ async def append_note( expected_ref_sha=expected_ref_sha, author=author, ttl=ttl, - refs=refs, + ref_policies=ref_policies, ) async def delete_note( @@ -1351,7 +1375,7 @@ async def delete_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: """Delete a git note.""" sha_clean = sha.strip() @@ -1359,7 +1383,7 @@ async def delete_note( raise ValueError("delete_note sha is required") ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ref_policies)) payload: Dict[str, Any] = {"sha": sha_clean} if expected_ref_sha and expected_ref_sha.strip(): @@ -1670,21 +1694,20 @@ async def pull_upstream( *, ref: Optional[str] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> None: """Pull from upstream repository. Args: ref: Git ref to pull ttl: Token TTL in seconds - ops: Repo-wide policy ops - refs: Ordered per-ref policy rules (first match wins) + ref_policies: Ordered per-ref policy rules (first match wins) Raises: ApiError: If pull fails """ ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ref_policies)) body = {} if ref: @@ -1718,7 +1741,7 @@ async def restore_commit( expected_head_sha: Optional[str] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> RestoreCommitResult: """Restore a previous commit. @@ -1754,7 +1777,7 @@ async def restore_commit( raise ValueError("restoreCommit author name and email are required") ttl = ttl or resolve_commit_ttl_seconds(None) - jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ref_policies)) metadata: Dict[str, Any] = { "target_branch": target_branch, @@ -1858,7 +1881,7 @@ def create_commit( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> CommitBuilder: """Create a new commit builder. @@ -1898,7 +1921,7 @@ def create_commit( def get_auth_token() -> str: return self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl, refs), + _build_jwt_options(["git:write"], ttl, ref_policies), ) return CommitBuilderImpl( @@ -1921,7 +1944,7 @@ async def create_commit_from_diff( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> CommitResult: """Create a commit by applying a unified diff.""" if diff is None: @@ -1949,7 +1972,7 @@ async def create_commit_from_diff( def get_auth_token() -> str: return self.generate_jwt( self._id, - _build_jwt_options(["git:write"], ttl_value, refs), + _build_jwt_options(["git:write"], ttl_value, ref_policies), ) return await send_diff_commit_request( @@ -1970,7 +1993,7 @@ async def _write_note( expected_ref_sha: Optional[str], author: Optional[CommitSignature], ttl: Optional[int], - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: sha_clean = sha.strip() if not sha_clean: @@ -1981,7 +2004,7 @@ async def _write_note( raise ValueError(f"{action_label} note is required") ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ref_policies)) payload: Dict[str, Any] = { "sha": sha_clean, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index b5f3671..88328f2 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -620,7 +620,7 @@ async def get_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> str: """Get the remote URL for the repository.""" ... @@ -631,7 +631,7 @@ async def get_import_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> str: """Get the import remote URL for the repository.""" ... @@ -642,7 +642,7 @@ async def get_ephemeral_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, - refs: Optional[Refs] = None, + ref_policies: Optional[Refs] = None, ) -> str: """Get the ephemeral remote URL for the repository.""" ... diff --git a/packages/code-storage-python/tests/ref_policies_live.py b/packages/code-storage-python/tests/ref_policies_live.py index 4859bec..b9c9e18 100644 --- a/packages/code-storage-python/tests/ref_policies_live.py +++ b/packages/code-storage-python/tests/ref_policies_live.py @@ -95,7 +95,7 @@ async def main() -> int: restricted_url = await repo.get_remote_url( permissions=["git:read", "git:write"], ttl=600, - refs=[ + ref_policies=[ {"pattern": "refs/heads/main", "ops": [OP_NO_PUSH]}, ], ) diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 7caa3fd..b9c586f 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -1101,7 +1101,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const body = buildNoteWriteBody(sha, note, 'add', { @@ -1147,7 +1147,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const body = buildNoteWriteBody(sha, note, 'append', { @@ -1188,7 +1188,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const body: Record = { @@ -1397,7 +1397,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const body: Record = {}; @@ -1437,7 +1437,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const body: Record = { @@ -1479,7 +1479,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const body: Record = { name }; @@ -1521,7 +1521,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const body: Record = { @@ -1598,7 +1598,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const response = await this.api.post( @@ -1622,7 +1622,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:read', 'git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const response = await this.api.delete( @@ -1662,7 +1662,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); const metadata: Record = { @@ -1740,7 +1740,7 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); return createCommitBuilder({ @@ -1767,7 +1767,7 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refs: options.refs, + refPolicies: options.refPolicies, }); return sendCommitFromDiff({ @@ -2148,8 +2148,8 @@ export class GitStorage { scopes: permissions, iat: now, exp: now + ttl, - ...(options?.refs && options.refs.length > 0 - ? { refs: encodeRefsClaim(options.refs) } + ...(options?.refPolicies && options.refPolicies.length > 0 + ? { refs: encodeRefsClaim(options.refPolicies) } : {}), ...(options?.ops && options.ops.length > 0 ? { ops: options.ops } : {}), }; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 227f825..03b62a9 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -67,13 +67,21 @@ export type RefPolicies = RefPolicy[]; /** Optional per-ref policies that can be attached to a minted JWT for any ref-mutating call. */ export interface PolicyOptions { /** Per-ref policy rules evaluated in declaration order (first match wins). */ - refs?: RefPolicies; + refPolicies?: RefPolicies; } export interface GetRemoteURLOptions extends PolicyOptions { permissions?: ('git:write' | 'git:read' | 'repo:write' | 'org:read')[]; ttl?: number; - /** Repo-wide policy ops (legacy; folded into a catch-all `*` rule on verify). */ + /** + * Repo-wide policy ops. + * + * @deprecated Use `refPolicies` instead. On verify the gateway folds `ops` + * into the catch-all `*` rule. It is merged into an existing `*` entry in + * `refPolicies` when one is present, or appended as a new trailing `*` + * rule otherwise. Prefer `refPolicies: [{ pattern: '*', ops: [...] }]` + * for new code. + */ ops?: Ops; } diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index ba742a6..c25c1ea 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -2550,7 +2550,7 @@ describe('GitStorage', () => { const repo = await store.createRepo({}); const url = await repo.getRemoteURL({ - refs: [ + refPolicies: [ { pattern: 'refs/heads/main', ops: ['no-push'] }, { pattern: '*', ops: ['no-force-push'] }, ], @@ -2571,7 +2571,7 @@ describe('GitStorage', () => { const repo = await store.createRepo({}); const url = await repo.getRemoteURL({ - refs: [ + refPolicies: [ { pattern: 'refs/heads/release/*', ops: [] }, { pattern: '*', ops: ['no-push'] }, ], diff --git a/packages/code-storage-typescript/tests/ref-policies-live.mjs b/packages/code-storage-typescript/tests/ref-policies-live.mjs index 4c48b1c..94df2c3 100644 --- a/packages/code-storage-typescript/tests/ref-policies-live.mjs +++ b/packages/code-storage-typescript/tests/ref-policies-live.mjs @@ -62,7 +62,7 @@ function patchGitStorage(GitStorage) { scopes: permissions, iat: now, exp: now + ttl, - ...(options?.refs?.length ? { refs: encodeRefsClaim(options.refs) } : {}), + ...(options?.refPolicies?.length ? { refs: encodeRefsClaim(options.refPolicies) } : {}), ...(options?.ops?.length ? { ops: options.ops } : {}), }; const { key, alg } = await resolveSigningKey(this.options.key); @@ -165,7 +165,7 @@ async function main() { const restrictedUrl = await repo.getRemoteURL({ permissions: ['git:read', 'git:write'], ttl: 600, - refs: [{ pattern: 'refs/heads/main', ops: ['no-push'] }], + refPolicies: [{ pattern: 'refs/heads/main', ops: ['no-push'] }], }); const openToken = tokenFromRemoteUrl(openUrl); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 5fddb3c..4c4ee97 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -62,26 +62,28 @@ JWT header: `{ "alg": "ES256", "typ": "JWT" }` (RS256 and EdDSA also supported) | `no-force-push` | TS `OP_NO_FORCE_PUSH` / Py `OP_NO_FORCE_PUSH` / Go `storage.OpNoForcePush` | Rejects force pushes / non-fast-forward ref updates. | | `no-push` | TS `OP_NO_PUSH` / Py `OP_NO_PUSH` / Go `storage.OpNoPush` | Rejects any push to matching refs. | -#### Per-ref `refs` (preferred — use this for new code) +#### Per-ref policies (preferred, use this for new code) The `refs` claim is an **ordered** array of `[pattern, [ops...]]` tuples. -Rules are evaluated in declaration order; the first pattern that matches the ref +Rules are evaluated in declaration order. The first pattern that matches the ref wins. Patterns may be fully-qualified refs (`refs/heads/main`), prefix globs (`refs/heads/feature/*`, `refs/tags/*`), or `*` for every ref. Short branch names -like `main` are normalized to `refs/heads/main` on verify. `refs` is accepted by -every URL-minting method and every ref-mutating REST method. +like `main` are normalized to `refs/heads/main` on verify. The policies are +accepted by every URL-minting method and every ref-mutating REST method via the +SDK option `refPolicies` (TS), `ref_policies` (Py), `RefPolicies` (Go). -#### Repo-wide `ops` (legacy — do not use in new code) +#### Repo-wide `ops` (legacy, do not use in new code) The optional top-level `ops` claim applies to every ref. On verify it is folded -into the catch-all `*` rule — merged into an existing `*` entry in `refs` if -one is present, or appended as a new trailing `*` rule otherwise. Only available -on the URL-minting methods (`getRemoteURL` / `getEphemeralRemoteURL` / -`getImportRemoteURL`). Use `refs: [{ pattern: '*', ops: [...] }]` instead. +into the catch-all `*` rule. It is merged into an existing `*` entry in the ref +policies when one is present, or appended as a new trailing `*` rule otherwise. +Only available on the URL-minting methods (`getRemoteURL` / +`getEphemeralRemoteURL` / `getImportRemoteURL`). Use +`refPolicies: [{ pattern: '*', ops: [...] }]` instead. ```typescript await repo.getRemoteURL({ - refs: [ + refPolicies: [ { pattern: 'refs/heads/main', ops: [OP_NO_PUSH] }, { pattern: '*', ops: [OP_NO_FORCE_PUSH] }, ], @@ -90,7 +92,7 @@ await repo.getRemoteURL({ ```python await repo.get_remote_url( - refs=[ + ref_policies=[ {"pattern": "refs/heads/main", "ops": [OP_NO_PUSH]}, {"pattern": "*", "ops": [OP_NO_FORCE_PUSH]}, ], @@ -99,7 +101,7 @@ await repo.get_remote_url( ```go repo.RemoteURL(ctx, storage.RemoteURLOptions{ - Refs: storage.RefPolicies{ + RefPolicies: storage.RefPolicyList{ {Pattern: "refs/heads/main", Ops: storage.Ops{storage.OpNoPush}}, {Pattern: "*", Ops: storage.Ops{storage.OpNoForcePush}}, }, @@ -277,8 +279,8 @@ curl "$CODE_STORAGE_BASE_URL/repos?limit=20&cursor=CURSOR&q=sdk" \ ``` Params: `cursor` (pagination), `limit` (default 20, max 100), `q` (optional -case-insensitive substring matched against the repository `url`; trimmed before -matching; empty/whitespace is treated as omitted) +case-insensitive substring matched against the repository `url`, trimmed before +matching, empty/whitespace is treated as omitted) Scope: `org:read` Response: `{ "repos": [...], "next_cursor": "...", "has_more": true }` @@ -312,7 +314,7 @@ curl "$CODE_STORAGE_BASE_URL/repos/branches/create" -X POST \ -d '{"base_ref":"refs/heads/main","target_branch":"feature/x","base_is_ephemeral":false,"target_is_ephemeral":false}' ``` -Required: `target_branch` plus one of `base_ref` (preferred — accepts `refs/heads/...`, +Required: `target_branch` plus one of `base_ref` (preferred, accepts `refs/heads/...`, plain branch names, or commit SHAs) or `base_branch` (deprecated alias). Optional: `base_is_ephemeral`, `target_is_ephemeral`. Response: `{ "message", "target_branch", "target_is_ephemeral", "commit_sha" }` @@ -360,13 +362,13 @@ Required: `source_branch`, `target_branch`, `strategy` (`merge` | `ff_only` | `f Optional: `source_is_ephemeral`, `target_is_ephemeral`, `expected_target_sha`, `commit_message`, `author`, `committer`, `allow_unrelated_histories`, `squash`. Set `squash: true` to collapse the source into a single new commit whose only -parent is the current target tip; incompatible with `ff_only`. +parent is the current target tip. It is incompatible with `ff_only`. Response: `{ "result": "merge_commit"|"fast_forward"|"no_op"|"squash"|"unknown", "commit_sha", "tree_sha", "source": {branch,ephemeral,sha}, "target": {branch,ephemeral,old_sha,new_sha}, "merge_base_sha?", "promoted_commits" }` TypeScript SDK 1.x normalizes a raw `result: "squash"` payload to `result: "merge_commit"` in its exported merge result types for semver -compatibility; Python and Go currently surface the raw label. +compatibility. Python and Go currently surface the raw label. Conflicts return HTTP 409 with `conflict_paths` and `merge_base_sha` preserved on the body. ## DELETE /repos/branches — Delete Branch @@ -500,10 +502,10 @@ curl "$CODE_STORAGE_BASE_URL/repos/blame?path=src/main.go&ref=main&range=10,30&r -H "Authorization: Bearer $CODE_STORAGE_TOKEN" ``` -Params: `path` (required — repository-relative file path), `ref` (branch, tag, or -SHA; defaults to the repository default branch), `ephemeral` (resolve `ref` from +Params: `path` (required, repository-relative file path), `ref` (branch, tag, or +SHA, defaults to the repository default branch), `ephemeral` (resolve `ref` from the ephemeral namespace), `range` (repeatable `git blame -L`-style spec, up to -16 per request — each value is one `-L` argument: e.g. `10,20`, `10,+5`, +16 per request, each value is one `-L` argument, e.g. `10,20`, `10,+5`, `/getUser/,/^}/`, `/getUser/,+30`, `10,`, `,20`, `10`, `:^func .*Foo`, `:funcname`; when omitted, the whole file is blamed), `detect_moves` (follow renames and copies). @@ -526,7 +528,7 @@ Response: } ``` The top-level `commit_sha` is the SHA the input ref resolved to. Each entry in -`lines[]` carries its authoring commit's metadata inline; `previous_commit_sha` +`lines[]` carries its authoring commit's metadata inline. `previous_commit_sha` is omitted when the line has no prior version (e.g. introduced in the initial commit). Errors: `400` missing/invalid params, `404` ref/path not found. @@ -822,9 +824,9 @@ const repo = store.repo({ id: 'team/project' }); const safeRemote = await repo.getRemoteURL({ permissions: ['git:write'], ttl: 3600, - refs: [{ pattern: '*', ops: [OP_NO_FORCE_PUSH] }], + refPolicies: [{ pattern: '*', ops: [OP_NO_FORCE_PUSH] }], }); -// git push to safeRemote — non-fast-forward updates are rejected. +// git push to safeRemote. Non-fast-forward updates are rejected. ``` ```python @@ -835,18 +837,19 @@ repo = store.repo(id="team/project") safe_remote = await repo.get_remote_url( permissions=["git:write"], ttl=3600, - refs=[{"pattern": "*", "ops": [OP_NO_FORCE_PUSH]}], + ref_policies=[{"pattern": "*", "ops": [OP_NO_FORCE_PUSH]}], ) ``` -`refs` is also accepted by `getEphemeralRemoteURL`, `getImportRemoteURL`, and -every ref-mutating REST method (`createBranch`, `merge`, `createCommit`, notes, -tags, etc.) — define the policy once and reuse it. When minting JWTs by hand, -add `"refs"` to the payload before signing. +The `refPolicies` option (`ref_policies` in Python, `RefPolicies` in Go) is also +accepted by `getEphemeralRemoteURL`, `getImportRemoteURL`, and every +ref-mutating REST method (`createBranch`, `merge`, `createCommit`, notes, tags, +etc.). Define the policy once and reuse it. When minting JWTs by hand, add the +`"refs"` claim to the payload before signing. > The legacy top-level `ops` claim is still accepted on URL-minting methods for > backwards compatibility (folded into a catch-all `*` rule on verify), but new -> code should use `refs` everywhere. +> code should use `refPolicies` everywhere. ## PROCEDURE 8: Rollback a Branch @@ -943,5 +946,5 @@ git push origin feature-branch | Pagination | Cursor-based. Pass `next_cursor` as `cursor` param. Stop when `has_more: false`. | | Blob data encoding | Always base64. Max 4 MiB per chunk. Use multiple chunks for large files. | | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. | -| Policy ops | JWT-level guards via `refs` (per-ref, first match wins; preferred). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates; `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. Top-level `ops` is a legacy alias on URL-minting methods only. | -| Merge endpoint | `POST /repos/merge`; strategies: `merge`, `ff_only`, `ff_prefer`; optional `squash` (not with `ff_only`). 409 on conflict. | +| Policy ops | JWT-level guards via `refPolicies` (per-ref, first match wins, preferred). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates. `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. Top-level `ops` is a legacy alias on URL-minting methods only. | +| Merge endpoint | `POST /repos/merge`. Strategies: `merge`, `ff_only`, `ff_prefer`. Optional `squash` (not with `ff_only`). 409 on conflict. | From 72f659f15dd416f4d94d802d8da6d78ebc859334 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 26 May 2026 11:44:44 -0400 Subject: [PATCH 7/9] ts: fix indentation of refPolicies in createCommit/createCommitFromDiff JWT options --- packages/code-storage-typescript/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index b9c586f..af09403 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -1740,7 +1740,7 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refPolicies: options.refPolicies, + refPolicies: options.refPolicies, }); return createCommitBuilder({ @@ -1767,7 +1767,7 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, - refPolicies: options.refPolicies, + refPolicies: options.refPolicies, }); return sendCommitFromDiff({ From afda2c5f7613b65c169658196db4677c828ffc7d Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 26 May 2026 13:18:49 -0400 Subject: [PATCH 8/9] 1231 --- packages/code-storage-go/types.go | 6 +- .../pierre_storage/repo.py | 5 +- .../tests/ref_policies_live.py | 172 ------------- packages/code-storage-typescript/src/types.ts | 152 +++++------ .../tests/ref-policies-live.mjs | 235 ------------------ 5 files changed, 81 insertions(+), 489 deletions(-) delete mode 100644 packages/code-storage-python/tests/ref_policies_live.py delete mode 100644 packages/code-storage-typescript/tests/ref-policies-live.mjs diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 2be8f91..b371f32 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -56,11 +56,7 @@ type RemoteURLOptions struct { TTL time.Duration // Ops is a repo-wide policy ops list. // - // Deprecated: Use RefPolicies instead. On verify the gateway folds Ops into - // the catch-all "*" rule. It is merged into an existing "*" entry in - // RefPolicies when one is present, or appended as a new trailing "*" rule - // otherwise. Prefer RefPolicies: RefPolicyList{{Pattern: "*", Ops: ...}} - // for new code. + // Deprecated: Use RefPolicies instead. Ops Ops // RefPolicies is evaluated in declaration order. The first matching rule wins. RefPolicies RefPolicyList diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 2a65687..ed48ca6 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -228,10 +228,7 @@ async def get_remote_url( Args: permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds - ops: Deprecated. Repo-wide policy operations. On verify the gateway - folds ``ops`` into the catch-all ``*`` rule. It is merged into - an existing ``*`` entry in ``ref_policies`` when one is present, - or appended as a new trailing ``*`` rule otherwise. Use + ops: Deprecated. Repo-wide policy operations. Use ``ref_policies=[{"pattern": "*", "ops": [...]}]`` instead. ref_policies: Ordered per-ref policy rules (first match wins) diff --git a/packages/code-storage-python/tests/ref_policies_live.py b/packages/code-storage-python/tests/ref_policies_live.py deleted file mode 100644 index b9c9e18..0000000 --- a/packages/code-storage-python/tests/ref_policies_live.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 -"""Live ref-policy smoke test against a running local git3p stack.""" - -from __future__ import annotations - -import asyncio -import os -import subprocess -import sys -import tempfile -import time -import uuid -from pathlib import Path - -import jwt - -from pierre_storage import OP_NO_PUSH, create_client - -def _default_key_path() -> Path: - if env := os.environ.get("GIT3P_KEY_PATH"): - return Path(env) - here = Path(__file__).resolve() - candidates = [ - here.parents[3] / "git3p-backend/hack/test-scripts/dev-keys/private.pem", - here.parents[4] / "monorepo/git3p-backend/hack/test-scripts/dev-keys/private.pem", - ] - for candidate in candidates: - if candidate.exists(): - return candidate - return candidates[-1] - - -KEY_PATH = _default_key_path() - -API_BASE = os.environ.get("GIT3P_API_URL", "http://127.0.0.1:8081") -STORAGE_HOST = os.environ.get("GIT3P_GIT_URL", "127.0.0.1:8080").replace("http://", "").replace("https://", "") -ISSUER = os.environ.get("GIT3P_ISSUER", "local") - - -def git(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", *args], - cwd=cwd, - text=True, - capture_output=True, - env={ - **os.environ, - "GIT_TERMINAL_PROMPT": "0", - "GIT_CONFIG_NOSYSTEM": "1", - "HOME": str(cwd), - }, - check=False, - ) - - -def build_git_remote(repo_id: str, token: str) -> str: - """Build a Git remote URL (HTTP for local dev; JWT may contain '@').""" - host = STORAGE_HOST - if "://" in host: - base = host.rstrip("/") - elif host.startswith("127.0.0.1") or host.startswith("localhost"): - base = f"http://{host}" - else: - base = f"https://{host}" - return f"{base}/{repo_id}.git".replace("://", f"://t:{token}@", 1) - - -def decode_refs(token: str) -> list: - payload = jwt.decode(token, options={"verify_signature": False}) - return payload.get("refs", []) - - -async def main() -> int: - if not KEY_PATH.exists(): - print(f"FAIL: signing key not found at {KEY_PATH}", file=sys.stderr) - return 1 - - key_pem = KEY_PATH.read_text() - repo_id = f"sdk-refpol-py-{int(time.time())}-{uuid.uuid4().hex[:6]}" - - client = create_client( - { - "name": ISSUER, - "key": key_pem, - "api_base_url": API_BASE, - "storage_base_url": STORAGE_HOST, - } - ) - - print(f"▶ Python ref-policy live test (repo={repo_id})") - repo = await client.create_repo(id=repo_id, default_branch="main") - print(" ✓ repo created") - - open_url = await repo.get_remote_url(permissions=["git:read", "git:write"], ttl=1800) - restricted_url = await repo.get_remote_url( - permissions=["git:read", "git:write"], - ttl=600, - ref_policies=[ - {"pattern": "refs/heads/main", "ops": [OP_NO_PUSH]}, - ], - ) - - open_token = open_url.split("://", 1)[1].rsplit("@", 1)[0].split(":", 1)[1] - restricted_token = restricted_url.split("://", 1)[1].rsplit("@", 1)[0].split(":", 1)[1] - refs_claim = decode_refs(restricted_token) - if refs_claim != [["refs/heads/main", ["no-push"]]]: - print(f"FAIL: unexpected refs claim in JWT: {refs_claim}", file=sys.stderr) - return 1 - print(f" ✓ JWT refs claim: {refs_claim}") - - with tempfile.TemporaryDirectory() as tmp: - work = Path(tmp) - for cmd in ( - ["init", "-b", "main"], - ["config", "user.email", "refpol@pierre.invalid"], - ["config", "user.name", "RefPol Live"], - ["config", "commit.gpgsign", "false"], - ): - git(*cmd, cwd=work) - - (work / "README.md").write_text("hello\n") - git("add", "README.md", cwd=work) - git("commit", "-m", "initial", cwd=work) - - git("remote", "add", "origin", build_git_remote(repo.id, open_token), cwd=work) - push = git("push", "-u", "origin", "main", cwd=work) - if push.returncode != 0: - print(f"FAIL: seed push failed:\n{push.stdout}{push.stderr}", file=sys.stderr) - return 1 - print(" ✓ seeded main via open token") - - git("checkout", "-b", "feature/allowed", cwd=work) - (work / "README.md").write_text("hello\nfeature\n") - git("add", "README.md", cwd=work) - git("commit", "-m", "feature commit", cwd=work) - - git("remote", "set-url", "origin", build_git_remote(repo.id, restricted_token), cwd=work) - feature_push = git("push", "-u", "origin", "feature/allowed", cwd=work) - if feature_push.returncode != 0: - print( - f"FAIL: feature push should succeed:\n{feature_push.stdout}{feature_push.stderr}", - file=sys.stderr, - ) - return 1 - print(" ✓ feature branch push allowed") - - git("checkout", "main", cwd=work) - (work / "README.md").write_text("hello\nblocked\n") - git("add", "README.md", cwd=work) - git("commit", "-m", "main blocked attempt", cwd=work) - main_push = git("push", "origin", "main", cwd=work) - combined = f"{main_push.stdout}{main_push.stderr}" - if main_push.returncode == 0: - print("FAIL: push to main should be denied by no-push policy", file=sys.stderr) - return 1 - if "denied by policy" not in combined: - print(f"FAIL: expected 'denied by policy' in push output:\n{combined}", file=sys.stderr) - return 1 - print(" ✓ main push denied by policy") - - try: - await client.delete_repo(id=repo_id) - print(" ✓ repo deleted") - except Exception as exc: # noqa: BLE001 - print(f" (cleanup warning: {exc})") - - print("✅ Python ref-policy live test passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(asyncio.run(main())) diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 03b62a9..d9d3cf1 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -28,7 +28,7 @@ import type { RawRepoBaseInfo as SchemaRawRepoBaseInfo, RawRepoInfo as SchemaRawRepoInfo, RawTagInfo as SchemaRawTagInfo, -} from './schemas'; +} from "./schemas"; export interface OverrideableGitStorageOptions { apiBaseUrl?: string; @@ -48,9 +48,9 @@ export type ValidAPIVersion = 1; /** A policy operation included in the JWT. */ export type Op = string; -export const OP_NO_FORCE_PUSH: Op = 'no-force-push'; +export const OP_NO_FORCE_PUSH: Op = "no-force-push"; -export const OP_NO_PUSH: Op = 'no-push'; +export const OP_NO_PUSH: Op = "no-push"; /** A list of policy operations. */ export type Ops = Op[]; @@ -71,16 +71,12 @@ export interface PolicyOptions { } export interface GetRemoteURLOptions extends PolicyOptions { - permissions?: ('git:write' | 'git:read' | 'repo:write' | 'org:read')[]; + permissions?: ("git:write" | "git:read" | "repo:write" | "org:read")[]; ttl?: number; /** * Repo-wide policy ops. * - * @deprecated Use `refPolicies` instead. On verify the gateway folds `ops` - * into the catch-all `*` rule. It is merged into an existing `*` entry in - * `refPolicies` when one is present, or appended as a new trailing `*` - * rule otherwise. Prefer `refPolicies: [{ pattern: '*', ops: [...] }]` - * for new code. + * @deprecated Use `refPolicies` instead. */ ops?: Ops; } @@ -97,7 +93,7 @@ export interface Repo { getArchiveStream(options?: ArchiveOptions): Promise; listFiles(options?: ListFilesOptions): Promise; listFilesWithMetadata( - options?: ListFilesWithMetadataOptions + options?: ListFilesWithMetadataOptions, ): Promise; listBranches(options?: ListBranchesOptions): Promise; listTags(options?: ListTagsOptions): Promise; @@ -120,11 +116,11 @@ export interface Repo { deleteBranch(options: DeleteBranchOptions): Promise; createCommit(options: CreateCommitOptions): CommitBuilder; createCommitFromDiff( - options: CreateCommitFromDiffOptions + options: CreateCommitFromDiffOptions, ): Promise; } -export type ValidMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; +export type ValidMethod = "GET" | "POST" | "PUT" | "DELETE"; type SimplePath = string; type ComplexPath = { path: string; @@ -148,26 +144,26 @@ export interface RepoOptions { } export type SupportedRepoProvider = - | 'github' - | 'gitlab' - | 'bitbucket' - | 'gitea' - | 'forgejo' - | 'codeberg' - | 'sr.ht'; + | "github" + | "gitlab" + | "bitbucket" + | "gitea" + | "forgejo" + | "codeberg" + | "sr.ht"; export interface PublicGitHubBaseRepoAuth { /** * Force public GitHub mode (no GitHub App installation required). */ - authType: 'public'; + authType: "public"; } export interface GitHubBaseRepo { /** * @default github */ - provider?: 'github'; + provider?: "github"; owner: string; name: string; defaultBranch?: string; @@ -178,7 +174,7 @@ export interface GenericGitBaseRepo { /** * The git host provider. Must be one of the supported generic git providers. */ - provider: Exclude; + provider: Exclude; owner: string; name: string; defaultBranch?: string; @@ -288,7 +284,8 @@ export interface ArchiveOptions extends GitStorageInvocationOptions { archivePrefix?: string; } -export interface PullUpstreamOptions extends GitStorageInvocationOptions, PolicyOptions { +export interface PullUpstreamOptions + extends GitStorageInvocationOptions, PolicyOptions { ref?: string; } @@ -361,7 +358,8 @@ export interface ListBranchesResult { } // Create Branch API types -export interface CreateBranchOptions extends GitStorageInvocationOptions, PolicyOptions { +export interface CreateBranchOptions + extends GitStorageInvocationOptions, PolicyOptions { baseRef?: string; /** @deprecated Use baseRef instead. */ baseBranch?: string; @@ -379,7 +377,8 @@ export interface CreateBranchResult { commitSha?: string; } -export interface DeleteBranchOptions extends GitStorageInvocationOptions, PolicyOptions { +export interface DeleteBranchOptions + extends GitStorageInvocationOptions, PolicyOptions { name: string; ephemeral?: boolean; } @@ -413,7 +412,8 @@ export interface ListTagsResult { hasMore: boolean; } -export interface CreateTagOptions extends GitStorageInvocationOptions, PolicyOptions { +export interface CreateTagOptions + extends GitStorageInvocationOptions, PolicyOptions { name: string; target: string; } @@ -426,7 +426,8 @@ export interface CreateTagResult { message: string; } -export interface DeleteTagOptions extends GitStorageInvocationOptions, PolicyOptions { +export interface DeleteTagOptions + extends GitStorageInvocationOptions, PolicyOptions { name: string; } @@ -525,7 +526,8 @@ export interface GetNoteResult { refSha: string; } -interface NoteWriteBaseOptions extends GitStorageInvocationOptions, PolicyOptions { +interface NoteWriteBaseOptions + extends GitStorageInvocationOptions, PolicyOptions { sha: string; note: string; expectedRefSha?: string; @@ -536,7 +538,8 @@ export type CreateNoteOptions = NoteWriteBaseOptions; export type AppendNoteOptions = NoteWriteBaseOptions; -export interface DeleteNoteOptions extends GitStorageInvocationOptions, PolicyOptions { +export interface DeleteNoteOptions + extends GitStorageInvocationOptions, PolicyOptions { sha: string; expectedRefSha?: string; author?: CommitSignature; @@ -669,14 +672,14 @@ export type RawFileDiff = SchemaRawFileDiff; export type RawFilteredFile = SchemaRawFilteredFile; export type DiffFileState = - | 'added' - | 'modified' - | 'deleted' - | 'renamed' - | 'copied' - | 'type_changed' - | 'unmerged' - | 'unknown'; + | "added" + | "modified" + | "deleted" + | "renamed" + | "copied" + | "type_changed" + | "unmerged" + | "unknown"; export interface DiffFileBase { path: string; @@ -695,7 +698,8 @@ export interface FileDiff extends DiffFileBase { export interface FilteredFile extends DiffFileBase {} -interface CreateCommitBaseOptions extends GitStorageInvocationOptions, PolicyOptions { +interface CreateCommitBaseOptions + extends GitStorageInvocationOptions, PolicyOptions { commitMessage: string; expectedHeadSha?: string; baseBranch?: string; @@ -746,21 +750,21 @@ export interface FileLike extends BlobLike { lastModified?: number; } -export type GitFileMode = '100644' | '100755' | '120000' | '160000'; +export type GitFileMode = "100644" | "100755" | "120000" | "160000"; export type TextEncoding = - | 'ascii' - | 'utf8' - | 'utf-8' - | 'utf16le' - | 'utf-16le' - | 'ucs2' - | 'ucs-2' - | 'base64' - | 'base64url' - | 'latin1' - | 'binary' - | 'hex'; + | "ascii" + | "utf8" + | "utf-8" + | "utf16le" + | "utf-16le" + | "ucs2" + | "ucs-2" + | "base64" + | "base64url" + | "latin1" + | "binary" + | "hex"; export type CommitFileSource = | string @@ -784,12 +788,12 @@ export interface CommitBuilder { addFile( path: string, source: CommitFileSource, - options?: CommitFileOptions + options?: CommitFileOptions, ): CommitBuilder; addFileFromString( path: string, contents: string, - options?: CommitTextFileOptions + options?: CommitTextFileOptions, ): CommitBuilder; deletePath(path: string): CommitBuilder; send(): Promise; @@ -818,17 +822,17 @@ export interface RefUpdate { } export type RefUpdateReason = - | 'precondition_failed' - | 'conflict' - | 'not_found' - | 'invalid' - | 'timeout' - | 'unauthorized' - | 'forbidden' - | 'unavailable' - | 'internal' - | 'failed' - | 'unknown'; + | "precondition_failed" + | "conflict" + | "not_found" + | "invalid" + | "timeout" + | "unauthorized" + | "forbidden" + | "unavailable" + | "internal" + | "failed" + | "unknown"; export interface CommitResult { commitSha: string; @@ -839,15 +843,16 @@ export interface CommitResult { refUpdate: RefUpdate; } -export type MergeStrategy = 'merge' | 'ff_only' | 'ff_prefer'; +export type MergeStrategy = "merge" | "ff_only" | "ff_prefer"; export type MergeResultLabel = - | 'merge_commit' - | 'fast_forward' - | 'no_op' - | 'unknown'; + | "merge_commit" + | "fast_forward" + | "no_op" + | "unknown"; -export interface MergeOptions extends GitStorageInvocationOptions, PolicyOptions { +export interface MergeOptions + extends GitStorageInvocationOptions, PolicyOptions { sourceBranch: string; sourceIsEphemeral?: boolean; targetBranch: string; @@ -862,7 +867,7 @@ export interface MergeOptions extends GitStorageInvocationOptions, PolicyOptions squash?: boolean; } -export type MergeResponse = Omit & { +export type MergeResponse = Omit & { result: MergeResultLabel; }; @@ -889,7 +894,8 @@ export interface MergeResult { promotedCommits: number; } -export interface RestoreCommitOptions extends GitStorageInvocationOptions, PolicyOptions { +export interface RestoreCommitOptions + extends GitStorageInvocationOptions, PolicyOptions { targetBranch: string; targetCommitSha: string; commitMessage?: string; @@ -948,7 +954,7 @@ export interface RawWebhookPushEvent { } export interface WebhookPushEvent { - type: 'push'; + type: "push"; repository: { id: string; url: string; diff --git a/packages/code-storage-typescript/tests/ref-policies-live.mjs b/packages/code-storage-typescript/tests/ref-policies-live.mjs deleted file mode 100644 index 94df2c3..0000000 --- a/packages/code-storage-typescript/tests/ref-policies-live.mjs +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env node -/** - * Live ref-policy smoke test against a running local git3p stack. - * Patches JWT signing for RS256 dev keys (same as full-workflow.js). - */ - -import { SignJWT, importPKCS8 } from 'jose'; -import { createPrivateKey } from 'node:crypto'; -import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const defaultKeyPath = path.resolve( - __dirname, - '../../../git3p-backend/hack/test-scripts/dev-keys/private.pem' -); -const monorepoKeyPath = path.resolve( - __dirname, - '../../../../monorepo/git3p-backend/hack/test-scripts/dev-keys/private.pem' -); - -const keyPath = process.env.GIT3P_KEY_PATH - ? path.resolve(process.env.GIT3P_KEY_PATH) - : existsSync(defaultKeyPath) - ? defaultKeyPath - : monorepoKeyPath; - -const apiBaseUrl = process.env.GIT3P_API_URL ?? 'http://127.0.0.1:8081'; -const storageBaseUrl = (process.env.GIT3P_GIT_URL ?? '127.0.0.1:8080') - .replace(/^https?:\/\//, ''); -const issuer = process.env.GIT3P_ISSUER ?? 'local'; -const keyId = process.env.GIT3P_KEY_ID ?? 'dev-key-001'; - -function decodeJwtPayload(token) { - const part = token.split('.')[1]; - return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); -} - -async function resolveSigningKey(pem) { - const keyObject = createPrivateKey({ key: pem, format: 'pem' }); - const alg = keyObject.asymmetricKeyType === 'rsa' ? 'RS256' : 'ES256'; - return { key: await importPKCS8(pem, alg), alg }; -} - -function patchGitStorage(GitStorage) { - GitStorage.prototype.generateJWT = async function patchedGenerateJWT( - repoId, - options - ) { - const permissions = options?.permissions ?? ['git:write', 'git:read']; - const ttl = options?.ttl ?? 365 * 24 * 60 * 60; - const now = Math.floor(Date.now() / 1000); - const payload = { - iss: this.options.name, - sub: '@pierre/storage', - repo: repoId, - scopes: permissions, - iat: now, - exp: now + ttl, - ...(options?.refPolicies?.length ? { refs: encodeRefsClaim(options.refPolicies) } : {}), - ...(options?.ops?.length ? { ops: options.ops } : {}), - }; - const { key, alg } = await resolveSigningKey(this.options.key); - const header = { alg, typ: 'JWT', kid: keyId }; - return new SignJWT(payload).setProtectedHeader(header).sign(key); - }; -} - -function git(args, { cwd }) { - return execFileSync('git', args, { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - GIT_TERMINAL_PROMPT: '0', - GIT_CONFIG_NOSYSTEM: '1', - HOME: cwd, - }, - }); -} - -function gitAllowFail(args, { cwd }) { - try { - const stdout = git(args, { cwd }); - return { code: 0, output: stdout }; - } catch (err) { - return { - code: err.status ?? 1, - output: `${err.stdout ?? ''}${err.stderr ?? ''}`, - }; - } -} - -function tokenFromRemoteUrl(url) { - const afterScheme = url.split('://', 2)[1]; - const at = afterScheme.lastIndexOf('@'); - const userinfo = afterScheme.slice(0, at); - return userinfo.slice(userinfo.indexOf(':') + 1); -} - -function buildGitRemote(repoId, token) { - const host = storageBaseUrl; - const base = - host.includes('://') - ? host.replace(/\/$/, '') - : host.startsWith('127.0.0.1') || host.startsWith('localhost') - ? `http://${host}` - : `https://${host}`; - return `${base}/${repoId}.git`.replace('://', `://t:${token}@`); -} - -let encodeRefsClaim; - -async function loadGitStorage() { - const candidates = [ - path.resolve(__dirname, '../dist/index.js'), - path.resolve(__dirname, '../dist/index.mjs'), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) { - const mod = await import(pathToFileURL(candidate).href); - encodeRefsClaim = mod.encodeRefsClaim; - return mod.GitStorage ?? mod.default; - } - } - throw new Error( - 'GitStorage dist build not found. Run "pnpm --filter @pierre/storage build" first.' - ); -} - -async function main() { - if (!existsSync(keyPath)) { - console.error(`FAIL: signing key not found at ${keyPath}`); - process.exit(1); - } - - const key = readFileSync(keyPath, 'utf8'); - const repoId = `sdk-refpol-ts-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; - - const GitStorage = await loadGitStorage(); - patchGitStorage(GitStorage); - - const store = new GitStorage({ - name: issuer, - key, - apiBaseUrl, - storageBaseUrl, - }); - - console.log(`▶ TypeScript ref-policy live test (repo=${repoId})`); - - const repo = await store.createRepo({ id: repoId, defaultBranch: 'main' }); - console.log(' ✓ repo created'); - - const openUrl = await repo.getRemoteURL({ - permissions: ['git:read', 'git:write'], - ttl: 1800, - }); - const restrictedUrl = await repo.getRemoteURL({ - permissions: ['git:read', 'git:write'], - ttl: 600, - refPolicies: [{ pattern: 'refs/heads/main', ops: ['no-push'] }], - }); - - const openToken = tokenFromRemoteUrl(openUrl); - const restrictedToken = tokenFromRemoteUrl(restrictedUrl); - const refsClaim = decodeJwtPayload(restrictedToken).refs; - const expected = [['refs/heads/main', ['no-push']]]; - if (JSON.stringify(refsClaim) !== JSON.stringify(expected)) { - console.error(`FAIL: unexpected refs claim: ${JSON.stringify(refsClaim)}`); - process.exit(1); - } - console.log(` ✓ JWT refs claim: ${JSON.stringify(refsClaim)}`); - - const work = mkdtempSync(path.join(os.tmpdir(), 'refpol-ts-')); - git(['init', '-b', 'main'], { cwd: work }); - git(['config', 'user.email', 'refpol@pierre.invalid'], { cwd: work }); - git(['config', 'user.name', 'RefPol Live'], { cwd: work }); - git(['config', 'commit.gpgsign', 'false'], { cwd: work }); - writeFileSync(path.join(work, 'README.md'), 'hello\n'); - git(['add', 'README.md'], { cwd: work }); - git(['commit', '-m', 'initial'], { cwd: work }); - - git(['remote', 'add', 'origin', buildGitRemote(repo.id, openToken)], { cwd: work }); - git(['push', '-u', 'origin', 'main'], { cwd: work }); - console.log(' ✓ seeded main via open token'); - - git(['checkout', '-b', 'feature/allowed'], { cwd: work }); - writeFileSync(path.join(work, 'README.md'), 'hello\nfeature\n'); - git(['add', 'README.md'], { cwd: work }); - git(['commit', '-m', 'feature commit'], { cwd: work }); - - git(['remote', 'set-url', 'origin', buildGitRemote(repo.id, restrictedToken)], { - cwd: work, - }); - git(['push', '-u', 'origin', 'feature/allowed'], { cwd: work }); - console.log(' ✓ feature branch push allowed'); - - git(['checkout', 'main'], { cwd: work }); - writeFileSync(path.join(work, 'README.md'), 'hello\nblocked\n'); - git(['add', 'README.md'], { cwd: work }); - git(['commit', '-m', 'main blocked attempt'], { cwd: work }); - const mainPush = gitAllowFail(['push', 'origin', 'main'], { cwd: work }); - if (mainPush.code === 0) { - console.error('FAIL: push to main should be denied by no-push policy'); - process.exit(1); - } - if (!mainPush.output.includes('denied by policy')) { - console.error( - `FAIL: expected 'denied by policy' in push output:\n${mainPush.output}` - ); - process.exit(1); - } - console.log(' ✓ main push denied by policy'); - - try { - await store.deleteRepo({ id: repoId }); - console.log(' ✓ repo deleted'); - } catch (err) { - console.log(` (cleanup warning: ${err.message})`); - } - - console.log('✅ TypeScript ref-policy live test passed'); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); From 982d433eb7a784d5fb37f2ebb874251cbb5f2549 Mon Sep 17 00:00:00 2001 From: Jon Crosby Date: Tue, 26 May 2026 13:05:42 -0500 Subject: [PATCH 9/9] python linter fix --- .../code-storage-python/pierre_storage/__init__.py | 14 +++++++------- .../code-storage-python/pierre_storage/repo.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index 6b5e5a7..35a2c89 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -7,6 +7,8 @@ from pierre_storage.client import GitStorage, create_client from pierre_storage.errors import ApiError, RefUpdateError from pierre_storage.types import ( + OP_NO_FORCE_PUSH, + OP_NO_PUSH, BaseRepo, BlameLine, BlameResult, @@ -18,8 +20,8 @@ CreateBranchResult, CreateTagResult, DeleteBranchResult, - DeleteTagResult, DeleteRepoResult, + DeleteTagResult, DiffFileState, DiffStats, FileDiff, @@ -29,12 +31,6 @@ GetCommitDiffResult, GetCommitResult, GitStorageOptions, - Op, - OP_NO_FORCE_PUSH, - OP_NO_PUSH, - Ops, - RefPolicy, - Refs, GrepFileMatch, GrepLine, GrepResult, @@ -46,6 +42,10 @@ ListTagsResult, NoteReadResult, NoteWriteResult, + Op, + Ops, + RefPolicy, + Refs, RefUpdate, Repo, RepoInfo, diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index ed48ca6..028afb0 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -49,8 +49,8 @@ MergeStrategy, NoteReadResult, NoteWriteResult, - RefUpdate, Refs, + RefUpdate, RestoreCommitResult, TagInfo, )