diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE diff --git a/README.md b/README similarity index 78% rename from README.md rename to README index 528bbd3..98d216a 100644 --- a/README.md +++ b/README @@ -1,4 +1,3 @@ -``` PIERRE COMPUTER COMPANY █ PROJECT: SDKS @@ -15,4 +14,5 @@ SDKS: - Typescript - Python - Go -``` + +[SDK DOCUMENTATION](https://code.storage/docs/reference/sdk) diff --git a/packages/code-storage-go/client.go b/packages/code-storage-go/client.go index 699ea6c..d217839 100644 --- a/packages/code-storage-go/client.go +++ b/packages/code-storage-go/client.go @@ -467,6 +467,9 @@ func (c *Client) generateJWT(repoID string, options RemoteURLOptions) (string, e "iat": issuedAt.Unix(), "exp": issuedAt.Add(ttl).Unix(), } + if len(options.Ops) > 0 { + claims["ops"] = options.Ops + } token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) return token.SignedString(c.privateKey) diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 1f1d2e0..40c23a7 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -67,6 +67,42 @@ func TestImportRemoteURL(t *testing.T) { } } +func TestRemoteURLOps(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 ops in JWT when provided", func(t *testing.T) { + remote, err := repo.RemoteURL(nil, RemoteURLOptions{ + Ops: Ops{OpNoForcePush}, + }) + if err != nil { + t.Fatalf("remote url error: %v", err) + } + claims := parseJWTFromURL(t, remote) + ops, ok := claims["ops"].([]interface{}) + if !ok { + t.Fatalf("expected ops claim to be a list") + } + if len(ops) != 1 || ops[0] != "no-force-push" { + t.Fatalf("unexpected ops: %v", ops) + } + }) + + t.Run("omits ops 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["ops"]; ok { + t.Fatalf("expected no ops 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 19bb6e7..dfbc6b5 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -30,10 +30,21 @@ type Options struct { HTTPClient *http.Client } +// Op is a policy operation included in the JWT. +type Op = string + +const ( + OpNoForcePush Op = "no-force-push" +) + +// Ops is a list of policy operations. +type Ops []Op + // RemoteURLOptions configure token generation for remote URLs. type RemoteURLOptions struct { Permissions []Permission TTL time.Duration + Ops Ops } // InvocationOptions holds common request options. diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index c13365a..5511172 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -25,6 +25,9 @@ GetBranchDiffResult, GetCommitDiffResult, GitStorageOptions, + Op, + OP_NO_FORCE_PUSH, + Ops, GrepFileMatch, GrepLine, GrepResult, @@ -83,6 +86,9 @@ "GrepLine", "GrepResult", "GitStorageOptions", + "Op", + "OP_NO_FORCE_PUSH", + "Ops", "ListBranchesResult", "ListCommitsResult", "ListFilesResult", diff --git a/packages/code-storage-python/pierre_storage/auth.py b/packages/code-storage-python/pierre_storage/auth.py index 4a06acb..e3c8287 100644 --- a/packages/code-storage-python/pierre_storage/auth.py +++ b/packages/code-storage-python/pierre_storage/auth.py @@ -13,6 +13,7 @@ def generate_jwt( repo_id: str, scopes: Optional[List[str]] = None, ttl: int = 31536000, # 1 year default + ops: Optional[List[str]] = None, ) -> str: """Generate a JWT token for Git storage authentication. @@ -22,6 +23,7 @@ def generate_jwt( repo_id: Repository identifier 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']) Returns: Signed JWT token string @@ -41,6 +43,8 @@ def generate_jwt( "iat": now, "exp": now + ttl, } + if ops: + payload["ops"] = ops # Load the private key and determine algorithm try: diff --git a/packages/code-storage-python/pierre_storage/client.py b/packages/code-storage-python/pierre_storage/client.py index 6242289..176745a 100644 --- a/packages/code-storage-python/pierre_storage/client.py +++ b/packages/code-storage-python/pierre_storage/client.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import Any, Dict, Optional, Union, cast +from typing import Any, Dict, List, Optional, Union, cast from urllib.parse import urlencode import httpx @@ -608,6 +608,7 @@ def _generate_jwt( """ permissions = ["git:write", "git:read"] ttl: int = 31536000 # 1 year default + ops: Optional[List[str]] = None if options: if "permissions" in options: @@ -616,6 +617,8 @@ def _generate_jwt( option_ttl = options["ttl"] if isinstance(option_ttl, int): ttl = option_ttl + if "ops" in options: + ops = options["ops"] elif "default_ttl" in self.options: default_ttl = self.options["default_ttl"] if isinstance(default_ttl, int): @@ -627,6 +630,7 @@ def _generate_jwt( repo_id, permissions, ttl, + ops, ) diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 0a88e49..57ec6e6 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -167,12 +167,14 @@ async def get_remote_url( *, permissions: Optional[list[str]] = None, ttl: Optional[int] = None, + ops: Optional[list[str]] = 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"]) Returns: Git remote URL with embedded JWT @@ -182,6 +184,8 @@ async def get_remote_url( options["permissions"] = permissions if ttl is not None: options["ttl"] = ttl + if ops is not None: + options["ops"] = ops 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" @@ -192,17 +196,19 @@ async def get_import_remote_url( *, permissions: Optional[list[str]] = None, ttl: Optional[int] = None, + ops: Optional[list[str]] = 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"]) Returns: Git remote URL with embedded JWT pointing to import namespace """ - url = await self.get_remote_url(permissions=permissions, ttl=ttl) + url = await self.get_remote_url(permissions=permissions, ttl=ttl, ops=ops) return url.replace(".git", "+import.git") async def get_ephemeral_remote_url( @@ -210,17 +216,19 @@ async def get_ephemeral_remote_url( *, permissions: Optional[list[str]] = None, ttl: Optional[int] = None, + ops: Optional[list[str]] = 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"]) Returns: Git remote URL with embedded JWT pointing to ephemeral namespace """ - url = await self.get_remote_url(permissions=permissions, ttl=ttl) + url = await self.get_remote_url(permissions=permissions, ttl=ttl, ops=ops) return url.replace(".git", "+ephemeral.git") async def get_file_stream( diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index cb7c38c..6f2febb 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -41,6 +41,15 @@ class GitStorageOptions(TypedDict, total=False): default_ttl: Optional[int] +# Op is a policy operation included in the JWT. +Op = str + +OP_NO_FORCE_PUSH: Op = "no-force-push" + +# Ops is a list of policy operations. +Ops = List[Op] + + class PublicGitHubBaseRepoAuth(TypedDict): """Authentication mode for GitHub base repositories.""" @@ -508,6 +517,7 @@ async def get_remote_url( *, permissions: Optional[list[str]] = None, ttl: Optional[int] = None, + ops: Optional[list[str]] = None, ) -> str: """Get the remote URL for the repository.""" ... @@ -517,6 +527,7 @@ async def get_import_remote_url( *, permissions: Optional[list[str]] = None, ttl: Optional[int] = None, + ops: Optional[list[str]] = None, ) -> str: """Get the import remote URL for the repository.""" ... @@ -526,6 +537,7 @@ async def get_ephemeral_remote_url( *, permissions: Optional[list[str]] = None, ttl: Optional[int] = None, + ops: Optional[list[str]] = None, ) -> str: """Get the ephemeral remote URL for the repository.""" ... diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index b217144..4570956 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.5.0" +version = "1.5.1" description = "Pierre Git Storage SDK for Python" readme = "README.md" license = "MIT" diff --git a/packages/code-storage-python/tests/test_client.py b/packages/code-storage-python/tests/test_client.py index 2299616..1a38dd5 100644 --- a/packages/code-storage-python/tests/test_client.py +++ b/packages/code-storage-python/tests/test_client.py @@ -1098,6 +1098,41 @@ def test_generate_jwt_with_all_parameters(self, test_key: str) -> None: assert payload["scopes"] == ["git:write", "git:read", "repo:write"] assert payload["exp"] - payload["iat"] == 7200 + def test_generate_jwt_with_ops(self, test_key: str) -> None: + """Test JWT generation with policy operations.""" + token = generate_jwt( + key_pem=test_key, + issuer="test-customer", + repo_id="test-repo", + ops=["no-force-push"], + ) + + payload = jwt.decode(token, options={"verify_signature": False}) + assert payload["ops"] == ["no-force-push"] + + def test_generate_jwt_without_ops(self, test_key: str) -> None: + """Test JWT generation omits ops 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 "ops" not in payload + + def test_generate_jwt_with_empty_ops(self, test_key: str) -> None: + """Test JWT generation omits ops when empty list.""" + token = generate_jwt( + key_pem=test_key, + issuer="test-customer", + repo_id="test-repo", + ops=[], + ) + + payload = jwt.decode(token, options={"verify_signature": False}) + assert "ops" 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-python/uv.lock b/packages/code-storage-python/uv.lock index 322adee..e396429 100644 --- a/packages/code-storage-python/uv.lock +++ b/packages/code-storage-python/uv.lock @@ -915,7 +915,7 @@ wheels = [ [[package]] name = "pierre-storage" -version = "1.3.2" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index f0f4585..aba0f08 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.4.0", + "version": "1.4.1", "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 54962fd..bb45531 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -1849,6 +1849,7 @@ export class GitStorage { scopes: permissions, iat: now, exp: now + ttl, + ...(options?.ops && options.ops.length > 0 ? { ops: options.ops } : {}), }; // Sign the JWT with the key as the secret diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 149255c..b826f48 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -41,9 +41,18 @@ export interface GitStorageOptions extends OverrideableGitStorageOptions { 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'; + +/** A list of policy operations. */ +export type Ops = Op[]; + export interface GetRemoteURLOptions { permissions?: ('git:write' | 'git:read' | 'repo:write' | 'org:read')[]; ttl?: number; + ops?: Ops; } export interface Repo { diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index bca0095..f6340b4 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -1831,6 +1831,42 @@ describe('GitStorage', () => { expect(jwt1).not.toBe(jwt2); }); + it('should include ops in JWT when provided', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + + const url = await repo.getRemoteURL({ + ops: ['no-force-push'], + }); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload.ops).toEqual(['no-force-push']); + }); + + it('should not include ops 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('ops'); + }); + + it('should not include ops in JWT when empty array', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + const url = await repo.getRemoteURL({ ops: [] }); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload).not.toHaveProperty('ops'); + }); + it('should include repo ID in URL path and JWT payload', async () => { const store = new GitStorage({ name: 'v0', key }); const customRepoId = 'my-custom-repo';