Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/code-storage-typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ const store = new GitStorage({
});
```

You can also supply a pre-minted JWT instead of a key. The client then sends
that token on every request rather than signing a fresh one per call. The
token's own claims (repo, scopes, expiry) govern what the client can do, so
per-call options like `permissions`, `ttl`, and `refPolicies` are ignored.

```typescript
const store = new GitStorage({
name: 'your-name',
token: 'your-pre-minted-jwt', // e.g., issued by your backend for an end user
});
```

### Creating a Repository

```typescript
Expand Down Expand Up @@ -510,7 +522,8 @@ class GitStorage {
```typescript
interface GitStorageOptions {
name: string; // Your identifier
key: string; // Your API key
key?: string; // Your ES256 private key, used to mint a JWT per call (required unless `token` is set)
token?: string; // A pre-minted JWT sent on every request instead of signing one from `key`
defaultTTL?: number; // Default TTL for generated JWTs (seconds)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/code-storage-typescript/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pierre/storage",
"version": "1.11.0",
"version": "1.12.0",
"description": "Pierre Git Storage SDK",
"repository": {
"type": "git",
Expand Down
35 changes: 30 additions & 5 deletions packages/code-storage-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1951,23 +1951,34 @@ export class GitStorage {
if (
!options ||
options.name === undefined ||
options.key === undefined ||
options.name === null ||
options.key === null
options.name === null
) {
throw new Error(
'GitStorage requires a name and key. Please check your configuration and try again.'
'GitStorage requires a name. Please check your configuration and try again.'
);
}

if (typeof options.name !== 'string' || options.name.trim() === '') {
throw new Error('GitStorage name must be a non-empty string.');
}

if (typeof options.key !== 'string' || options.key.trim() === '') {
const hasKey = options.key !== undefined && options.key !== null;
const hasToken = options.token !== undefined && options.token !== null;

if (!hasKey && !hasToken) {
throw new Error(
'GitStorage requires either a key or a token. Please check your configuration and try again.'
);
}

if (hasKey && (typeof options.key !== 'string' || options.key.trim() === '')) {
throw new Error('GitStorage key must be a non-empty string.');
}

if (hasToken && (typeof options.token !== 'string' || options.token.trim() === '')) {
throw new Error('GitStorage token must be a non-empty string.');
}

const resolvedApiBaseUrl =
options.apiBaseUrl ?? GitStorage.getDefaultAPIBaseUrl(options.name);
const resolvedApiVersion = options.apiVersion ?? API_VERSION;
Expand All @@ -1980,6 +1991,7 @@ export class GitStorage {

this.options = {
key: options.key,
token: options.token,
name: options.name,
apiBaseUrl: resolvedApiBaseUrl,
apiVersion: resolvedApiVersion,
Expand Down Expand Up @@ -2297,6 +2309,19 @@ export class GitStorage {
repoId: string,
options?: GetRemoteURLOptions
): Promise<string> {
// When the caller supplied a pre-minted token, use it verbatim. Its own
// claims (repo, scopes, exp) govern access, so per-call permission, TTL,
// and ref-policy options do not apply.
if (this.options.token) {
return this.options.token;
}

if (!this.options.key) {
throw new Error(
'GitStorage requires a key to generate a JWT. Please check your configuration and try again.'
);
}

// Default permissions and TTL
const permissions = options?.permissions || ['git:write', 'git:read'];
const ttl = resolveInvocationTtlSeconds(
Expand Down
13 changes: 12 additions & 1 deletion packages/code-storage-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,19 @@ export interface OverrideableGitStorageOptions {
}

export interface GitStorageOptions extends OverrideableGitStorageOptions {
key: string;
/**
* ES256 private key (PKCS#8 PEM) used to mint a fresh JWT for each API call.
* Required unless `token` is supplied.
*/
key?: string;
name: string;
/**
* A pre-minted JWT to send on every authenticated request instead of signing
* one per call from `key`. When set, `key` is not required and per-call scope,
* TTL, and ref-policy options are ignored — the token's own claims (repo,
* scopes, exp) govern what the client is allowed to do.
*/
token?: string;
defaultTTL?: number;
}

Expand Down
66 changes: 58 additions & 8 deletions packages/code-storage-typescript/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,42 +70,51 @@ describe('GitStorage', () => {
expect(config.key).toBe(key);
});

it('should throw error when key is missing', () => {
it('should throw error when name is missing', () => {
expect(() => {
// @ts-expect-error - Testing missing key
// @ts-expect-error - Testing missing name
new GitStorage({});
}).toThrow(
'GitStorage requires a name and key. Please check your configuration and try again.'
'GitStorage requires a name. Please check your configuration and try again.'
);
});

it('should throw error when name or key is null or undefined', () => {
it('should throw error when neither key nor token is supplied', () => {
expect(() => {
// @ts-expect-error - Testing missing credential
new GitStorage({ name: 'v0' });
}).toThrow(
'GitStorage requires either a key or a token. Please check your configuration and try again.'
);

expect(() => {
// @ts-expect-error - Testing null key
new GitStorage({ name: 'v0', key: null });
}).toThrow(
'GitStorage requires a name and key. Please check your configuration and try again.'
'GitStorage requires either a key or a token. Please check your configuration and try again.'
);

expect(() => {
// @ts-expect-error - Testing undefined key
new GitStorage({ name: 'v0', key: undefined });
}).toThrow(
'GitStorage requires a name and key. Please check your configuration and try again.'
'GitStorage requires either a key or a token. Please check your configuration and try again.'
);
});

it('should throw error when name is null or undefined', () => {
expect(() => {
// @ts-expect-error - Testing null name
new GitStorage({ name: null, key: 'test-key' });
}).toThrow(
'GitStorage requires a name and key. Please check your configuration and try again.'
'GitStorage requires a name. Please check your configuration and try again.'
);

expect(() => {
// @ts-expect-error - Testing undefined name
new GitStorage({ name: undefined, key: 'test-key' });
}).toThrow(
'GitStorage requires a name and key. Please check your configuration and try again.'
'GitStorage requires a name. Please check your configuration and try again.'
);
});

Expand Down Expand Up @@ -156,6 +165,47 @@ describe('GitStorage', () => {
new GitStorage({ name: {}, key: 'test-key' });
}).toThrow('GitStorage name must be a non-empty string.');
});

it('should construct with a token instead of a key', () => {
const store = new GitStorage({ name: 'v0', token: 'supplied.jwt.token' });
expect(store).toBeInstanceOf(GitStorage);
});

it('should throw error when token is empty string', () => {
expect(() => {
new GitStorage({ name: 'v0', token: ' ' });
}).toThrow('GitStorage token must be a non-empty string.');
});
});

it('sends a supplied token verbatim instead of minting a JWT', async () => {
const suppliedToken = 'header.payload.signature';
const store = new GitStorage({ name: 'v0', token: suppliedToken });
const repo = await store.createRepo({ id: 'repo-supplied-token' });

mockFetch.mockImplementationOnce((_url, init) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(stripBearer(headers.Authorization)).toBe(suppliedToken);
return Promise.resolve({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
commit: {
sha: 'abc123',
message: 'msg',
author_name: 'A',
author_email: 'a@example.com',
committer_name: 'A',
committer_email: 'a@example.com',
date: '2024-01-15T14:32:18Z',
},
}),
} as any);
});

const result = await repo.getCommit({ sha: 'abc123' });
expect(result.commit.sha).toBe('abc123');
});

it('parses commit dates into Date instances', async () => {
Expand Down
Loading