@shipfox/api-auth-context 10.2.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
1
  $ shipfox-swc
2
- Successfully compiled: 1 file with swc (236.73ms)
2
+ Successfully compiled: 1 file with swc (173.9ms)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @shipfox/api-auth-context
2
2
 
3
+ ## 11.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 25158c8: Carry workspace lifecycle status in JWT membership claims and enforce suspended or inactive access at the stateless workspace gate while keeping access-token verification stateless.
8
+
9
+ `getAuthenticatedSessionContext()` now reads refresh-session metadata from verified access-token claims without checking active refresh-session state; revoking a refresh session does not invalidate an already-issued access token.
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [25158c8]
14
+ - @shipfox/api-workspaces-dto@11.0.0
15
+
3
16
  ## 10.2.0
4
17
 
5
18
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { JobLeaseTokenClaims, RunnerSessionTokenClaims } from '@shipfox/api-auth-dto';
2
2
  import type { WorkspaceRole } from '@shipfox/api-workspaces-dto';
3
+ import { ClientError } from '@shipfox/node-fastify';
3
4
  export declare const AUTH_USER = "user";
4
5
  export declare const AUTH_RUNNER_REGISTRATION_TOKEN = "runner-registration-token";
5
6
  export declare const AUTH_RUNNER_SESSION = "runner-session";
@@ -10,6 +11,7 @@ export type WorkspaceStatus = 'active' | 'suspended' | 'deleted';
10
11
  export interface UserContextMembership {
11
12
  workspaceId: string;
12
13
  role: WorkspaceRole;
14
+ workspaceStatus: WorkspaceStatus;
13
15
  }
14
16
  export interface UserContext {
15
17
  userId: string;
@@ -60,6 +62,16 @@ export interface RequireWorkspaceAccessResult {
60
62
  * stateless and does not read the database on the request path.
61
63
  */
62
64
  export declare function requireWorkspaceAccess(params: RequireWorkspaceAccessParams): RequireWorkspaceAccessResult;
65
+ /**
66
+ * Applies the workspace lifecycle gate to a resource that has already been loaded. Missing
67
+ * membership remains resource-shaped 404 to avoid leaking the resource's existence, while
68
+ * suspended and deleted claims retain their stable lifecycle errors.
69
+ */
70
+ export declare function requireWorkspaceResourceAccess(params: {
71
+ request: RequestWithContext;
72
+ workspaceId: string;
73
+ notFoundError: ClientError;
74
+ }): RequireWorkspaceAccessResult;
63
75
  export declare function setProvisionerContext(request: RequestWithContext, context: ProvisionerContext): void;
64
76
  export declare function getProvisionerContext(request: RequestWithContext): ProvisionerContext | null;
65
77
  export declare function requireProvisionerContext(request: RequestWithContext): ProvisionerContext;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -12,8 +12,8 @@ export function buildUserContext(params) {
12
12
  email: params.email,
13
13
  name: params.name ?? null,
14
14
  memberships,
15
- canAccess: (workspaceId)=>memberships.some((m)=>m.workspaceId === workspaceId),
16
- hasRole: (workspaceId, role)=>memberships.some((m)=>m.workspaceId === workspaceId && m.role === role)
15
+ canAccess: (workspaceId)=>memberships.some((m)=>m.workspaceId === workspaceId && m.workspaceStatus === 'active'),
16
+ hasRole: (workspaceId, role)=>memberships.some((m)=>m.workspaceId === workspaceId && m.workspaceStatus === 'active' && m.role === role)
17
17
  };
18
18
  }
19
19
  const USER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/user');
@@ -51,12 +51,36 @@ export function requireUserContext(request) {
51
51
  status: 403
52
52
  });
53
53
  }
54
+ if (membership.workspaceStatus === 'suspended') {
55
+ throw new ClientError('Workspace is suspended', 'workspace-suspended', {
56
+ status: 409
57
+ });
58
+ }
59
+ if (membership.workspaceStatus === 'deleted') {
60
+ throw new ClientError('Workspace is not active', 'workspace-inactive', {
61
+ status: 403
62
+ });
63
+ }
54
64
  return {
55
65
  workspaceId: params.workspaceId,
56
66
  userId: context.userId,
57
67
  role: membership.role
58
68
  };
59
69
  }
70
+ /**
71
+ * Applies the workspace lifecycle gate to a resource that has already been loaded. Missing
72
+ * membership remains resource-shaped 404 to avoid leaking the resource's existence, while
73
+ * suspended and deleted claims retain their stable lifecycle errors.
74
+ */ export function requireWorkspaceResourceAccess(params) {
75
+ try {
76
+ return requireWorkspaceAccess(params);
77
+ } catch (error) {
78
+ if (error instanceof ClientError && error.code === 'forbidden') {
79
+ throw params.notFoundError;
80
+ }
81
+ throw error;
82
+ }
83
+ }
60
84
  export function setProvisionerContext(request, context) {
61
85
  request[PROVISIONER_CONTEXT_KEY] = context;
62
86
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type {JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';\nimport type {WorkspaceRole} from '@shipfox/api-workspaces-dto';\nimport {ClientError} from '@shipfox/node-fastify';\n\nexport const AUTH_USER = 'user';\nexport const AUTH_RUNNER_REGISTRATION_TOKEN = 'runner-registration-token';\nexport const AUTH_RUNNER_SESSION = 'runner-session';\nexport const AUTH_RUNNER_CONTROL_SESSION = 'runner-control-session';\nexport const AUTH_LEASED_JOB = 'leased-job';\nexport const AUTH_PROVISIONER_TOKEN = 'provisioner-token';\n\nexport type WorkspaceStatus = 'active' | 'suspended' | 'deleted';\n\nexport interface UserContextMembership {\n workspaceId: string;\n role: WorkspaceRole;\n}\n\nexport interface UserContext {\n userId: string;\n email: string;\n name: string | null;\n memberships: ReadonlyArray<UserContextMembership>;\n canAccess(workspaceId: string): boolean;\n hasRole(workspaceId: string, role: WorkspaceRole): boolean;\n}\n\nexport interface BuildUserContextParams {\n userId: string;\n email: string;\n name?: string | null | undefined;\n memberships?: ReadonlyArray<UserContextMembership> | undefined;\n}\n\nexport function buildUserContext(params: BuildUserContextParams): UserContext {\n const memberships = params.memberships ?? [];\n return {\n userId: params.userId,\n email: params.email,\n name: params.name ?? null,\n memberships,\n canAccess: (workspaceId) => memberships.some((m) => m.workspaceId === workspaceId),\n hasRole: (workspaceId, role) =>\n memberships.some((m) => m.workspaceId === workspaceId && m.role === role),\n };\n}\n\nexport type ProvisionerContext =\n | {provisionerTokenId: string; scope: 'installation'}\n | {provisionerTokenId: string; scope: 'workspace'; workspaceId: string};\n\nexport type LeasedJobContext = JobLeaseTokenClaims;\nexport type RunnerSessionContext = RunnerSessionTokenClaims;\nexport interface RunnerControlSessionContext {\n runnerControlSessionId: string;\n runnerInstanceId: string;\n provisionerId: string;\n}\n\ntype RequestWithContext = object;\n\nconst USER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/user');\nconst LEASED_JOB_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/leased-job');\nconst PROVISIONER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/provisioner');\nconst RUNNER_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/runner-session');\nconst RUNNER_CONTROL_SESSION_CONTEXT_KEY = Symbol.for(\n '@shipfox/api-auth-context/runner-control-session',\n);\n\nexport function setUserContext(request: RequestWithContext, context: UserContext): void {\n (request as Record<symbol, unknown>)[USER_CONTEXT_KEY] = context;\n}\n\nexport function getUserContext(request: RequestWithContext): UserContext | null {\n return (\n ((request as Record<symbol, unknown>)[USER_CONTEXT_KEY] as UserContext | undefined) ?? null\n );\n}\n\nexport function requireUserContext(request: RequestWithContext): UserContext {\n const context = getUserContext(request);\n if (!context) {\n throw new Error('User context is not available on this request');\n }\n return context;\n}\n\nexport interface RequireWorkspaceAccessParams {\n request: RequestWithContext;\n workspaceId: string;\n}\n\nexport interface RequireWorkspaceAccessResult {\n workspaceId: string;\n userId: string;\n role: WorkspaceRole;\n}\n\n/**\n * Authorizes the request's user for a workspace using only the verified session\n * token. Membership and role come from the token claims, so the check stays\n * stateless and does not read the database on the request path.\n */\nexport function requireWorkspaceAccess(\n params: RequireWorkspaceAccessParams,\n): RequireWorkspaceAccessResult {\n const context = getUserContext(params.request);\n if (!context) {\n throw new ClientError('Authentication required', 'unauthorized', {status: 401});\n }\n\n const membership = context.memberships.find((m) => m.workspaceId === params.workspaceId);\n if (!membership) {\n throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});\n }\n\n return {workspaceId: params.workspaceId, userId: context.userId, role: membership.role};\n}\n\nexport function setProvisionerContext(\n request: RequestWithContext,\n context: ProvisionerContext,\n): void {\n (request as Record<symbol, unknown>)[PROVISIONER_CONTEXT_KEY] = context;\n}\n\nexport function getProvisionerContext(request: RequestWithContext): ProvisionerContext | null {\n return (\n ((request as Record<symbol, unknown>)[PROVISIONER_CONTEXT_KEY] as\n | ProvisionerContext\n | undefined) ?? null\n );\n}\n\nexport function requireProvisionerContext(request: RequestWithContext): ProvisionerContext {\n const context = getProvisionerContext(request);\n if (!context) {\n throw new Error('Provisioner context is not available on this request');\n }\n return context;\n}\n\nexport function requireWorkspaceProvisionerContext(\n request: RequestWithContext,\n): Extract<ProvisionerContext, {scope: 'workspace'}> {\n const context = requireProvisionerContext(request);\n if (context.scope !== 'workspace') {\n throw new ClientError('Workspace provisioner credential required', 'forbidden', {status: 403});\n }\n return context;\n}\n\nexport function requireInstallationProvisionerContext(\n request: RequestWithContext,\n): Extract<ProvisionerContext, {scope: 'installation'}> {\n const context = requireProvisionerContext(request);\n if (context.scope !== 'installation') {\n throw new ClientError('Installation provisioner credential required', 'forbidden', {\n status: 403,\n });\n }\n return context;\n}\n\nexport function setLeasedJobContext(request: RequestWithContext, context: LeasedJobContext): void {\n (request as Record<symbol, unknown>)[LEASED_JOB_CONTEXT_KEY] = context;\n}\n\nexport function getLeasedJobContext(request: RequestWithContext): LeasedJobContext | null {\n return (\n ((request as Record<symbol, unknown>)[LEASED_JOB_CONTEXT_KEY] as\n | LeasedJobContext\n | undefined) ?? null\n );\n}\n\nexport function requireLeasedJobContext(request: RequestWithContext): LeasedJobContext {\n const context = getLeasedJobContext(request);\n if (!context) {\n throw new Error('Leased job context is not available on this request');\n }\n return context;\n}\n\nexport function setRunnerSessionContext(\n request: RequestWithContext,\n context: RunnerSessionContext,\n): void {\n (request as Record<symbol, unknown>)[RUNNER_SESSION_CONTEXT_KEY] = context;\n}\n\nexport function getRunnerSessionContext(request: RequestWithContext): RunnerSessionContext | null {\n return (\n ((request as Record<symbol, unknown>)[RUNNER_SESSION_CONTEXT_KEY] as\n | RunnerSessionContext\n | undefined) ?? null\n );\n}\n\nexport function requireRunnerSessionContext(request: RequestWithContext): RunnerSessionContext {\n const context = getRunnerSessionContext(request);\n if (!context) {\n throw new Error('Runner session context is not available on this request');\n }\n return context;\n}\n\nexport function setRunnerControlSessionContext(\n request: RequestWithContext,\n context: RunnerControlSessionContext,\n): void {\n (request as Record<symbol, unknown>)[RUNNER_CONTROL_SESSION_CONTEXT_KEY] = context;\n}\n\nexport function requireRunnerControlSessionContext(\n request: RequestWithContext,\n): RunnerControlSessionContext {\n const context = (request as Record<symbol, unknown>)[RUNNER_CONTROL_SESSION_CONTEXT_KEY] as\n | RunnerControlSessionContext\n | undefined;\n if (!context) throw new Error('Runner control session context is not available on this request');\n return context;\n}\n"],"names":["ClientError","AUTH_USER","AUTH_RUNNER_REGISTRATION_TOKEN","AUTH_RUNNER_SESSION","AUTH_RUNNER_CONTROL_SESSION","AUTH_LEASED_JOB","AUTH_PROVISIONER_TOKEN","buildUserContext","params","memberships","userId","email","name","canAccess","workspaceId","some","m","hasRole","role","USER_CONTEXT_KEY","Symbol","for","LEASED_JOB_CONTEXT_KEY","PROVISIONER_CONTEXT_KEY","RUNNER_SESSION_CONTEXT_KEY","RUNNER_CONTROL_SESSION_CONTEXT_KEY","setUserContext","request","context","getUserContext","requireUserContext","Error","requireWorkspaceAccess","status","membership","find","setProvisionerContext","getProvisionerContext","requireProvisionerContext","requireWorkspaceProvisionerContext","scope","requireInstallationProvisionerContext","setLeasedJobContext","getLeasedJobContext","requireLeasedJobContext","setRunnerSessionContext","getRunnerSessionContext","requireRunnerSessionContext","setRunnerControlSessionContext","requireRunnerControlSessionContext"],"mappings":"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"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type {JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';\nimport type {WorkspaceRole} from '@shipfox/api-workspaces-dto';\nimport {ClientError} from '@shipfox/node-fastify';\n\nexport const AUTH_USER = 'user';\nexport const AUTH_RUNNER_REGISTRATION_TOKEN = 'runner-registration-token';\nexport const AUTH_RUNNER_SESSION = 'runner-session';\nexport const AUTH_RUNNER_CONTROL_SESSION = 'runner-control-session';\nexport const AUTH_LEASED_JOB = 'leased-job';\nexport const AUTH_PROVISIONER_TOKEN = 'provisioner-token';\n\nexport type WorkspaceStatus = 'active' | 'suspended' | 'deleted';\n\nexport interface UserContextMembership {\n workspaceId: string;\n role: WorkspaceRole;\n workspaceStatus: WorkspaceStatus;\n}\n\nexport interface UserContext {\n userId: string;\n email: string;\n name: string | null;\n memberships: ReadonlyArray<UserContextMembership>;\n canAccess(workspaceId: string): boolean;\n hasRole(workspaceId: string, role: WorkspaceRole): boolean;\n}\n\nexport interface BuildUserContextParams {\n userId: string;\n email: string;\n name?: string | null | undefined;\n memberships?: ReadonlyArray<UserContextMembership> | undefined;\n}\n\nexport function buildUserContext(params: BuildUserContextParams): UserContext {\n const memberships = params.memberships ?? [];\n return {\n userId: params.userId,\n email: params.email,\n name: params.name ?? null,\n memberships,\n canAccess: (workspaceId) =>\n memberships.some((m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active'),\n hasRole: (workspaceId, role) =>\n memberships.some(\n (m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active' && m.role === role,\n ),\n };\n}\n\nexport type ProvisionerContext =\n | {provisionerTokenId: string; scope: 'installation'}\n | {provisionerTokenId: string; scope: 'workspace'; workspaceId: string};\n\nexport type LeasedJobContext = JobLeaseTokenClaims;\nexport type RunnerSessionContext = RunnerSessionTokenClaims;\nexport interface RunnerControlSessionContext {\n runnerControlSessionId: string;\n runnerInstanceId: string;\n provisionerId: string;\n}\n\ntype RequestWithContext = object;\n\nconst USER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/user');\nconst LEASED_JOB_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/leased-job');\nconst PROVISIONER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/provisioner');\nconst RUNNER_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/runner-session');\nconst RUNNER_CONTROL_SESSION_CONTEXT_KEY = Symbol.for(\n '@shipfox/api-auth-context/runner-control-session',\n);\n\nexport function setUserContext(request: RequestWithContext, context: UserContext): void {\n (request as Record<symbol, unknown>)[USER_CONTEXT_KEY] = context;\n}\n\nexport function getUserContext(request: RequestWithContext): UserContext | null {\n return (\n ((request as Record<symbol, unknown>)[USER_CONTEXT_KEY] as UserContext | undefined) ?? null\n );\n}\n\nexport function requireUserContext(request: RequestWithContext): UserContext {\n const context = getUserContext(request);\n if (!context) {\n throw new Error('User context is not available on this request');\n }\n return context;\n}\n\nexport interface RequireWorkspaceAccessParams {\n request: RequestWithContext;\n workspaceId: string;\n}\n\nexport interface RequireWorkspaceAccessResult {\n workspaceId: string;\n userId: string;\n role: WorkspaceRole;\n}\n\n/**\n * Authorizes the request's user for a workspace using only the verified session\n * token. Membership and role come from the token claims, so the check stays\n * stateless and does not read the database on the request path.\n */\nexport function requireWorkspaceAccess(\n params: RequireWorkspaceAccessParams,\n): RequireWorkspaceAccessResult {\n const context = getUserContext(params.request);\n if (!context) {\n throw new ClientError('Authentication required', 'unauthorized', {status: 401});\n }\n\n const membership = context.memberships.find((m) => m.workspaceId === params.workspaceId);\n if (!membership) {\n throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});\n }\n\n if (membership.workspaceStatus === 'suspended') {\n throw new ClientError('Workspace is suspended', 'workspace-suspended', {status: 409});\n }\n if (membership.workspaceStatus === 'deleted') {\n throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});\n }\n\n return {workspaceId: params.workspaceId, userId: context.userId, role: membership.role};\n}\n\n/**\n * Applies the workspace lifecycle gate to a resource that has already been loaded. Missing\n * membership remains resource-shaped 404 to avoid leaking the resource's existence, while\n * suspended and deleted claims retain their stable lifecycle errors.\n */\nexport function requireWorkspaceResourceAccess(params: {\n request: RequestWithContext;\n workspaceId: string;\n notFoundError: ClientError;\n}): RequireWorkspaceAccessResult {\n try {\n return requireWorkspaceAccess(params);\n } catch (error) {\n if (error instanceof ClientError && error.code === 'forbidden') {\n throw params.notFoundError;\n }\n throw error;\n }\n}\n\nexport function setProvisionerContext(\n request: RequestWithContext,\n context: ProvisionerContext,\n): void {\n (request as Record<symbol, unknown>)[PROVISIONER_CONTEXT_KEY] = context;\n}\n\nexport function getProvisionerContext(request: RequestWithContext): ProvisionerContext | null {\n return (\n ((request as Record<symbol, unknown>)[PROVISIONER_CONTEXT_KEY] as\n | ProvisionerContext\n | undefined) ?? null\n );\n}\n\nexport function requireProvisionerContext(request: RequestWithContext): ProvisionerContext {\n const context = getProvisionerContext(request);\n if (!context) {\n throw new Error('Provisioner context is not available on this request');\n }\n return context;\n}\n\nexport function requireWorkspaceProvisionerContext(\n request: RequestWithContext,\n): Extract<ProvisionerContext, {scope: 'workspace'}> {\n const context = requireProvisionerContext(request);\n if (context.scope !== 'workspace') {\n throw new ClientError('Workspace provisioner credential required', 'forbidden', {status: 403});\n }\n return context;\n}\n\nexport function requireInstallationProvisionerContext(\n request: RequestWithContext,\n): Extract<ProvisionerContext, {scope: 'installation'}> {\n const context = requireProvisionerContext(request);\n if (context.scope !== 'installation') {\n throw new ClientError('Installation provisioner credential required', 'forbidden', {\n status: 403,\n });\n }\n return context;\n}\n\nexport function setLeasedJobContext(request: RequestWithContext, context: LeasedJobContext): void {\n (request as Record<symbol, unknown>)[LEASED_JOB_CONTEXT_KEY] = context;\n}\n\nexport function getLeasedJobContext(request: RequestWithContext): LeasedJobContext | null {\n return (\n ((request as Record<symbol, unknown>)[LEASED_JOB_CONTEXT_KEY] as\n | LeasedJobContext\n | undefined) ?? null\n );\n}\n\nexport function requireLeasedJobContext(request: RequestWithContext): LeasedJobContext {\n const context = getLeasedJobContext(request);\n if (!context) {\n throw new Error('Leased job context is not available on this request');\n }\n return context;\n}\n\nexport function setRunnerSessionContext(\n request: RequestWithContext,\n context: RunnerSessionContext,\n): void {\n (request as Record<symbol, unknown>)[RUNNER_SESSION_CONTEXT_KEY] = context;\n}\n\nexport function getRunnerSessionContext(request: RequestWithContext): RunnerSessionContext | null {\n return (\n ((request as Record<symbol, unknown>)[RUNNER_SESSION_CONTEXT_KEY] as\n | RunnerSessionContext\n | undefined) ?? null\n );\n}\n\nexport function requireRunnerSessionContext(request: RequestWithContext): RunnerSessionContext {\n const context = getRunnerSessionContext(request);\n if (!context) {\n throw new Error('Runner session context is not available on this request');\n }\n return context;\n}\n\nexport function setRunnerControlSessionContext(\n request: RequestWithContext,\n context: RunnerControlSessionContext,\n): void {\n (request as Record<symbol, unknown>)[RUNNER_CONTROL_SESSION_CONTEXT_KEY] = context;\n}\n\nexport function requireRunnerControlSessionContext(\n request: RequestWithContext,\n): RunnerControlSessionContext {\n const context = (request as Record<symbol, unknown>)[RUNNER_CONTROL_SESSION_CONTEXT_KEY] as\n | RunnerControlSessionContext\n | undefined;\n if (!context) throw new Error('Runner control session context is not available on this request');\n return context;\n}\n"],"names":["ClientError","AUTH_USER","AUTH_RUNNER_REGISTRATION_TOKEN","AUTH_RUNNER_SESSION","AUTH_RUNNER_CONTROL_SESSION","AUTH_LEASED_JOB","AUTH_PROVISIONER_TOKEN","buildUserContext","params","memberships","userId","email","name","canAccess","workspaceId","some","m","workspaceStatus","hasRole","role","USER_CONTEXT_KEY","Symbol","for","LEASED_JOB_CONTEXT_KEY","PROVISIONER_CONTEXT_KEY","RUNNER_SESSION_CONTEXT_KEY","RUNNER_CONTROL_SESSION_CONTEXT_KEY","setUserContext","request","context","getUserContext","requireUserContext","Error","requireWorkspaceAccess","status","membership","find","requireWorkspaceResourceAccess","error","code","notFoundError","setProvisionerContext","getProvisionerContext","requireProvisionerContext","requireWorkspaceProvisionerContext","scope","requireInstallationProvisionerContext","setLeasedJobContext","getLeasedJobContext","requireLeasedJobContext","setRunnerSessionContext","getRunnerSessionContext","requireRunnerSessionContext","setRunnerControlSessionContext","requireRunnerControlSessionContext"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth-context",
3
3
  "license": "MIT",
4
- "version": "10.2.0",
4
+ "version": "11.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@shipfox/api-auth-dto": "10.2.0",
22
- "@shipfox/api-workspaces-dto": "10.2.0",
22
+ "@shipfox/api-workspaces-dto": "11.0.0",
23
23
  "@shipfox/node-fastify": "0.4.0"
24
24
  },
25
25
  "shipfox": {
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export type WorkspaceStatus = 'active' | 'suspended' | 'deleted';
14
14
  export interface UserContextMembership {
15
15
  workspaceId: string;
16
16
  role: WorkspaceRole;
17
+ workspaceStatus: WorkspaceStatus;
17
18
  }
18
19
 
19
20
  export interface UserContext {
@@ -39,9 +40,12 @@ export function buildUserContext(params: BuildUserContextParams): UserContext {
39
40
  email: params.email,
40
41
  name: params.name ?? null,
41
42
  memberships,
42
- canAccess: (workspaceId) => memberships.some((m) => m.workspaceId === workspaceId),
43
+ canAccess: (workspaceId) =>
44
+ memberships.some((m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active'),
43
45
  hasRole: (workspaceId, role) =>
44
- memberships.some((m) => m.workspaceId === workspaceId && m.role === role),
46
+ memberships.some(
47
+ (m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active' && m.role === role,
48
+ ),
45
49
  };
46
50
  }
47
51
 
@@ -114,9 +118,36 @@ export function requireWorkspaceAccess(
114
118
  throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});
115
119
  }
116
120
 
121
+ if (membership.workspaceStatus === 'suspended') {
122
+ throw new ClientError('Workspace is suspended', 'workspace-suspended', {status: 409});
123
+ }
124
+ if (membership.workspaceStatus === 'deleted') {
125
+ throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});
126
+ }
127
+
117
128
  return {workspaceId: params.workspaceId, userId: context.userId, role: membership.role};
118
129
  }
119
130
 
131
+ /**
132
+ * Applies the workspace lifecycle gate to a resource that has already been loaded. Missing
133
+ * membership remains resource-shaped 404 to avoid leaking the resource's existence, while
134
+ * suspended and deleted claims retain their stable lifecycle errors.
135
+ */
136
+ export function requireWorkspaceResourceAccess(params: {
137
+ request: RequestWithContext;
138
+ workspaceId: string;
139
+ notFoundError: ClientError;
140
+ }): RequireWorkspaceAccessResult {
141
+ try {
142
+ return requireWorkspaceAccess(params);
143
+ } catch (error) {
144
+ if (error instanceof ClientError && error.code === 'forbidden') {
145
+ throw params.notFoundError;
146
+ }
147
+ throw error;
148
+ }
149
+ }
150
+
120
151
  export function setProvisionerContext(
121
152
  request: RequestWithContext,
122
153
  context: ProvisionerContext,
@@ -26,7 +26,10 @@ describe('requireWorkspaceAccess', () => {
26
26
  test('returns workspaceId, userId, and role when the token grants access', () => {
27
27
  const userId = crypto.randomUUID();
28
28
  const workspaceId = crypto.randomUUID();
29
- const request = requestWith({userId, memberships: [{workspaceId, role: 'admin'}]});
29
+ const request = requestWith({
30
+ userId,
31
+ memberships: [{workspaceId, role: 'admin', workspaceStatus: 'active'}],
32
+ });
30
33
 
31
34
  const result = requireWorkspaceAccess({request, workspaceId});
32
35
 
@@ -38,7 +41,7 @@ describe('requireWorkspaceAccess', () => {
38
41
  test('throws 403 when the token does not include the workspace', () => {
39
42
  const request = requestWith({
40
43
  userId: crypto.randomUUID(),
41
- memberships: [{workspaceId: crypto.randomUUID(), role: 'admin'}],
44
+ memberships: [{workspaceId: crypto.randomUUID(), role: 'admin', workspaceStatus: 'active'}],
42
45
  });
43
46
 
44
47
  const act = () => requireWorkspaceAccess({request, workspaceId: crypto.randomUUID()});
@@ -53,4 +56,35 @@ describe('requireWorkspaceAccess', () => {
53
56
  expect(act).toThrow(ClientError);
54
57
  expect(act).toThrow(expect.objectContaining({status: 401}));
55
58
  });
59
+
60
+ test.each([
61
+ ['suspended', 'workspace-suspended', 409],
62
+ ['deleted', 'workspace-inactive', 403],
63
+ ] as const)('maps a %s workspace claim to the stable access error', (workspaceStatus, code, status) => {
64
+ const workspaceId = crypto.randomUUID();
65
+ const request = requestWith({
66
+ userId: crypto.randomUUID(),
67
+ memberships: [{workspaceId, role: 'admin', workspaceStatus}],
68
+ });
69
+
70
+ const act = () => requireWorkspaceAccess({request, workspaceId});
71
+
72
+ expect(act).toThrow(ClientError);
73
+ expect(act).toThrow(expect.objectContaining({code, status}));
74
+ });
75
+ });
76
+
77
+ test('context access helpers only authorize active memberships', () => {
78
+ const workspaceId = crypto.randomUUID();
79
+ const context = buildUserContext({
80
+ userId: crypto.randomUUID(),
81
+ email: 'user@example.com',
82
+ memberships: [
83
+ {workspaceId, role: 'admin', workspaceStatus: 'suspended'},
84
+ {workspaceId: crypto.randomUUID(), role: 'admin', workspaceStatus: 'deleted'},
85
+ ],
86
+ });
87
+
88
+ expect(context.canAccess(workspaceId)).toBe(false);
89
+ expect(context.hasRole(workspaceId, 'admin')).toBe(false);
56
90
  });