@learncard/learn-cloud-service 2.5.34 → 2.5.36

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.
Files changed (124) hide show
  1. package/.env.example +24 -12
  2. package/CHANGELOG.md +43 -0
  3. package/bootstrapLambda.test.ts +68 -0
  4. package/config/config.dev.json +8 -0
  5. package/config/config.example.json +8 -0
  6. package/config/config.json +1 -0
  7. package/config/config.production.json +8 -0
  8. package/config/config.scouts.dev.json +3 -0
  9. package/config/config.scouts.production.json +4 -0
  10. package/didWebLambda.test.ts +41 -0
  11. package/didWebLambda.ts +13 -19
  12. package/didWebLambdaApp.ts +23 -0
  13. package/dist/accesslayer/share-content/delete.d.ts +5 -0
  14. package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
  15. package/dist/accesslayer/share-content/index.d.ts +8 -0
  16. package/dist/accesslayer/share-content/index.d.ts.map +1 -0
  17. package/dist/accesslayer/share-content/initialize.d.ts +5 -0
  18. package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
  19. package/dist/accesslayer/share-content/internal.d.ts +12 -0
  20. package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
  21. package/dist/accesslayer/share-content/put.d.ts +5 -0
  22. package/dist/accesslayer/share-content/put.d.ts.map +1 -0
  23. package/dist/accesslayer/share-content/read.d.ts +7 -0
  24. package/dist/accesslayer/share-content/read.d.ts.map +1 -0
  25. package/dist/accesslayer/share-content/types.d.ts +85 -0
  26. package/dist/accesslayer/share-content/types.d.ts.map +1 -0
  27. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  28. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  29. package/dist/config/environment.d.ts +12 -0
  30. package/dist/config/environment.d.ts.map +1 -1
  31. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  32. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  33. package/dist/config/stageConfig.d.ts +10 -0
  34. package/dist/config/stageConfig.d.ts.map +1 -0
  35. package/dist/config/stageConfig.test.d.ts +2 -0
  36. package/dist/config/stageConfig.test.d.ts.map +1 -0
  37. package/dist/didkit_wasm_bg.wasm +0 -0
  38. package/dist/docker-entry.d.ts +1 -1
  39. package/dist/docker-entry.d.ts.map +1 -1
  40. package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
  41. package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
  42. package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
  43. package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
  44. package/dist/helpers/share-content-auth/claims.d.ts +19 -0
  45. package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
  46. package/dist/helpers/share-content-auth/config.d.ts +12 -0
  47. package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
  48. package/dist/helpers/share-content-auth/index.d.ts +8 -0
  49. package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
  50. package/dist/helpers/share-content-auth/token.d.ts +28 -0
  51. package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
  52. package/dist/helpers/share-content-auth/types.d.ts +70 -0
  53. package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
  54. package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
  55. package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
  56. package/dist/index.js +2 -2
  57. package/dist/models/ShareContent.d.ts +160 -0
  58. package/dist/models/ShareContent.d.ts.map +1 -0
  59. package/dist/models/index.d.ts +1 -0
  60. package/dist/models/index.d.ts.map +1 -1
  61. package/dist/share-content/config.d.ts +28 -0
  62. package/dist/share-content/config.d.ts.map +1 -0
  63. package/dist/share-content/index.d.ts +6 -0
  64. package/dist/share-content/index.d.ts.map +1 -0
  65. package/dist/share-content/plugin.d.ts +5 -0
  66. package/dist/share-content/plugin.d.ts.map +1 -0
  67. package/dist/share-content/production.d.ts +15 -0
  68. package/dist/share-content/production.d.ts.map +1 -0
  69. package/dist/share-content/runtime.d.ts +23 -0
  70. package/dist/share-content/runtime.d.ts.map +1 -0
  71. package/dist/share-content/types.d.ts +23 -0
  72. package/dist/share-content/types.d.ts.map +1 -0
  73. package/esbuildPlugins.cjs +1 -0
  74. package/lambda.ts +22 -92
  75. package/lambdaApp.ts +163 -0
  76. package/oidcLambda.test.ts +41 -0
  77. package/oidcLambda.ts +13 -19
  78. package/oidcLambdaApp.ts +23 -0
  79. package/package.json +18 -16
  80. package/serverless-local.yml +3 -1
  81. package/serverless.function-env.cjs +31 -0
  82. package/serverless.yml +31 -22
  83. package/src/accesslayer/share-content/delete.ts +124 -0
  84. package/src/accesslayer/share-content/index.ts +70 -0
  85. package/src/accesslayer/share-content/initialize.ts +24 -0
  86. package/src/accesslayer/share-content/internal.ts +89 -0
  87. package/src/accesslayer/share-content/put.ts +93 -0
  88. package/src/accesslayer/share-content/read.ts +124 -0
  89. package/src/accesslayer/share-content/types.ts +155 -0
  90. package/src/config/applyDockerStageConfig.ts +6 -0
  91. package/src/config/environment.ts +33 -0
  92. package/src/config/serverlessFunctionEnv.test.ts +81 -0
  93. package/src/config/stageConfig.test.ts +26 -0
  94. package/src/config/stageConfig.ts +24 -0
  95. package/src/docker-entry.ts +21 -0
  96. package/src/helpers/share-content-auth/adapters.ts +48 -0
  97. package/src/helpers/share-content-auth/canonical.ts +91 -0
  98. package/src/helpers/share-content-auth/claims.ts +164 -0
  99. package/src/helpers/share-content-auth/config.ts +160 -0
  100. package/src/helpers/share-content-auth/index.ts +59 -0
  101. package/src/helpers/share-content-auth/token.ts +148 -0
  102. package/src/helpers/share-content-auth/types.ts +195 -0
  103. package/src/helpers/share-content-auth/verifier.ts +220 -0
  104. package/src/models/ShareContent.ts +198 -0
  105. package/src/models/index.ts +1 -0
  106. package/src/share-content/config.ts +199 -0
  107. package/src/share-content/index.ts +5 -0
  108. package/src/share-content/plugin.ts +317 -0
  109. package/src/share-content/production.ts +107 -0
  110. package/src/share-content/runtime.ts +90 -0
  111. package/src/share-content/types.ts +59 -0
  112. package/test/share-content-auth.spec.ts +1161 -0
  113. package/test/share-content-config.spec.ts +252 -0
  114. package/test/share-content-didkit.spec.ts +411 -0
  115. package/test/share-content-endpoint.spec.ts +825 -0
  116. package/test/share-content-redis-setup.ts +18 -0
  117. package/test/share-content.spec.ts +681 -0
  118. package/vitest.config.ts +4 -0
  119. package/vitest.share-content-config-unit.config.ts +8 -0
  120. package/vitest.share-content-endpoint.config.ts +21 -0
  121. package/vitest.share-content.config.ts +19 -0
  122. package/xApiLambda.test.ts +41 -0
  123. package/xApiLambda.ts +13 -19
  124. package/xApiLambdaApp.ts +23 -0
@@ -29,6 +29,13 @@ export const learnCloudServiceEnvironmentShape = {
29
29
  IS_OFFLINE: optionalEnvironmentBoolean.default(false),
30
30
  SKIP_DIDKIT_NAPI: optionalEnvironmentBoolean.default(false),
31
31
  CI: optionalEnvironmentBoolean.default(false),
32
+ // LC-2187 service-only share-content routes. Available once an operator
33
+ // provisions an explicit audience, service DID allowlist, exact verification
34
+ // methods and per-service namespace bindings.
35
+ SHARE_CONTENT_AUDIENCE: optionalEnvironmentString,
36
+ SHARE_CONTENT_SERVICE_DIDS: optionalEnvironmentString,
37
+ SHARE_CONTENT_VERIFICATION_METHODS: optionalEnvironmentString,
38
+ SHARE_CONTENT_NAMESPACE_BINDINGS: optionalEnvironmentString,
32
39
  } satisfies z.ZodRawShape;
33
40
 
34
41
  export const learnCloudServiceEnvironmentSchema = z
@@ -56,6 +63,32 @@ export const learnCloudServiceEnvironmentSchema = z
56
63
  message: 'RSA_PRIVATE_KEY and RSA_PUBLIC_KEY must be configured together',
57
64
  });
58
65
  }
66
+
67
+ if (
68
+ [
69
+ environment.SHARE_CONTENT_AUDIENCE,
70
+ environment.SHARE_CONTENT_SERVICE_DIDS,
71
+ environment.SHARE_CONTENT_VERIFICATION_METHODS,
72
+ environment.SHARE_CONTENT_NAMESPACE_BINDINGS,
73
+ ].some(Boolean)
74
+ ) {
75
+ const requiredShareContentFields = [
76
+ 'SHARE_CONTENT_AUDIENCE',
77
+ 'SHARE_CONTENT_SERVICE_DIDS',
78
+ 'SHARE_CONTENT_VERIFICATION_METHODS',
79
+ 'SHARE_CONTENT_NAMESPACE_BINDINGS',
80
+ ] as const;
81
+
82
+ for (const field of requiredShareContentFields) {
83
+ if (!environment[field]) {
84
+ context.addIssue({
85
+ code: 'custom',
86
+ path: [field],
87
+ message: 'Required when share-content service configuration is supplied',
88
+ });
89
+ }
90
+ }
91
+ }
59
92
  });
60
93
 
61
94
  export type LearnCloudServiceEnvironment = z.output<typeof learnCloudServiceEnvironmentSchema>;
@@ -0,0 +1,81 @@
1
+ import { createRequire } from 'node:module';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const functions: {
6
+ provider: (context?: {
7
+ options: { stage: string; httpPort?: string };
8
+ }) => Record<string, unknown>;
9
+ } = require('../../serverless.function-env.cjs');
10
+
11
+ beforeEach(() => {
12
+ vi.stubEnv('RUNTIME_SECRETS_ID', '');
13
+ vi.stubEnv('LEARN_CLOUD_SEED', 'seed');
14
+ vi.stubEnv('LEARN_CLOUD_MONGO_URI', 'mongodb://localhost:27017');
15
+ // XAPI_ENDPOINT is a user-classified bundle secret, so populate a stale fallback value
16
+ // to prove it never leaks onto the provider once a runtime bundle is configured.
17
+ vi.stubEnv('XAPI_ENDPOINT', 'https://stale.xapi.example.com');
18
+ });
19
+ afterEach(() => vi.unstubAllEnvs());
20
+
21
+ describe('learn-cloud-service serverless function environments', () => {
22
+ it('omits all credential fallbacks in bundle mode', () => {
23
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'learn-cloud-service/dev/runtime-secrets');
24
+ expect(Object.keys(functions.provider()).sort()).toEqual([
25
+ 'CONFIG_TENANT',
26
+ 'LAMBDA_STAGE',
27
+ 'PORT',
28
+ 'REDIS_HOST',
29
+ 'REDIS_PORT',
30
+ 'RUNTIME_SECRETS_ID',
31
+ ]);
32
+ });
33
+
34
+ it('drops the XAPI_ENDPOINT bundle secret in bundle mode even when a stale fallback is set', () => {
35
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'learn-cloud-service/dev/runtime-secrets');
36
+ expect(functions.provider()).not.toHaveProperty('XAPI_ENDPOINT');
37
+ });
38
+
39
+ it('retains the existing provider credentials only in fallback mode', () => {
40
+ const provider = functions.provider();
41
+ expect(provider.LEARN_CLOUD_SEED).toBe('seed');
42
+ expect(provider.LEARN_CLOUD_MONGO_URI).toBe('mongodb://localhost:27017');
43
+ expect(provider.XAPI_ENDPOINT).toBe('https://stale.xapi.example.com');
44
+ expect(provider.RUNTIME_SECRETS_ID).toBeUndefined();
45
+ });
46
+
47
+ it('preserves the CloudFormation Redis intrinsics and CLI options', () => {
48
+ const provider = functions.provider({ options: { stage: 'production', httpPort: '5100' } });
49
+ expect(provider.LAMBDA_STAGE).toBe('production');
50
+ expect(provider.PORT).toBe('5100');
51
+ expect(provider.REDIS_HOST).toEqual({
52
+ 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Address'],
53
+ });
54
+ expect(provider.REDIS_PORT).toEqual({
55
+ 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Port'],
56
+ });
57
+ });
58
+
59
+ it.each(['', 'bundle'])(
60
+ 'resolves the full provider environment through Serverless v3 (%s)',
61
+ async bundle => {
62
+ const source = require('serverless/lib/configuration/variables/sources/file');
63
+ vi.stubEnv('RUNTIME_SECRETS_ID', bundle);
64
+ const options = { stage: 'production', httpPort: '5100' };
65
+ const result = await source.resolve({
66
+ serviceDir: new URL('../../', import.meta.url).pathname,
67
+ params: [new URL('../../serverless.function-env.cjs', import.meta.url).pathname],
68
+ address: 'provider',
69
+ options,
70
+ resolveConfigurationProperty: vi.fn(),
71
+ resolveVariable: vi.fn(),
72
+ });
73
+ expect(result.value).toEqual(functions.provider({ options }));
74
+ expect(result.value.LAMBDA_STAGE).toBe('production');
75
+ expect(result.value.PORT).toBe('5100');
76
+ expect(result.value.REDIS_HOST).toEqual({
77
+ 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Address'],
78
+ });
79
+ }
80
+ );
81
+ });
@@ -0,0 +1,26 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+
3
+ // Model a non-empty .env value arriving before the stage defaults are applied.
4
+ vi.mock('dotenv/config', () => {
5
+ vi.stubEnv('SENTRY_ENV', 'self-hosted');
6
+ return {};
7
+ });
8
+
9
+ afterEach(() => vi.unstubAllEnvs());
10
+
11
+ it('loads local env before applying statically imported stage defaults', async () => {
12
+ const { applyLearnCloudStageConfig, stages } = await import('./stageConfig');
13
+ for (const key of Object.keys(stages.dev)) {
14
+ if (key !== 'SENTRY_ENV') vi.stubEnv(key, '');
15
+ }
16
+ applyLearnCloudStageConfig('dev');
17
+ expect(process.env.SENTRY_ENV).toBe('self-hosted');
18
+ expect(process.env.SERVER_URL).toBe(stages.dev.SERVER_URL);
19
+ });
20
+
21
+ it('does not select a deployed stage for an unknown local stage', async () => {
22
+ const { applyLearnCloudStageConfig } = await import('./stageConfig');
23
+ vi.stubEnv('SERVER_URL', '');
24
+ applyLearnCloudStageConfig('unregistered-self-hosted-stage');
25
+ expect(process.env.SERVER_URL).toBe('');
26
+ });
@@ -0,0 +1,24 @@
1
+ import 'dotenv/config';
2
+ import { applyStageConfig } from '@learncard/service-config';
3
+
4
+ import baseConfig from '../../config/config.json';
5
+ import devConfig from '../../config/config.dev.json';
6
+ import productionConfig from '../../config/config.production.json';
7
+ import scoutsDevConfig from '../../config/config.scouts.dev.json';
8
+ import scoutsProductionConfig from '../../config/config.scouts.production.json';
9
+
10
+ export const base: Record<string, string> = baseConfig;
11
+
12
+ export const stages = {
13
+ dev: devConfig,
14
+ production: productionConfig,
15
+ // ScoutPass deploys the same services and stage names; CONFIG_TENANT=scouts selects these.
16
+ 'scouts.dev': scoutsDevConfig,
17
+ 'scouts.production': scoutsProductionConfig,
18
+ } satisfies Record<string, Record<string, string>>;
19
+
20
+ // Must run before any module that reads configuration on import (e.g. @environment):
21
+ // merges base + the selected stage into process.env; explicit non-empty env still wins.
22
+ export const applyLearnCloudStageConfig = (stage: string | undefined): void => {
23
+ applyStageConfig({ base, stages, stage });
24
+ };
@@ -1,3 +1,5 @@
1
+ import './config/applyDockerStageConfig';
2
+
1
3
  import { environment } from '@environment';
2
4
  import path from 'path';
3
5
 
@@ -11,6 +13,11 @@ import { openApiDocument } from './openapi';
11
13
  import { didFastifyPlugin } from './dids';
12
14
  import { xapiFastifyPlugin } from './xapi';
13
15
  import { oidcFastifyPlugin } from './oidc';
16
+ import {
17
+ createShareContentRuntimeFromEnvironment,
18
+ registerShareContentRoutes,
19
+ type ShareContentRuntime,
20
+ } from './share-content';
14
21
 
15
22
  const server = Fastify({ maxParamLength: 5000 });
16
23
 
@@ -52,10 +59,24 @@ server.register(xapiFastifyPlugin);
52
59
  server.register(oidcFastifyPlugin);
53
60
 
54
61
  (async () => {
62
+ let shareContentRuntime: ShareContentRuntime | null = null;
63
+
55
64
  try {
65
+ // Disabled by default: this performs no Mongo/Redis/index work unless an
66
+ // operator explicitly enables the routes with a valid configuration.
67
+ shareContentRuntime = await createShareContentRuntimeFromEnvironment();
68
+
69
+ if (shareContentRuntime.enabled) {
70
+ await registerShareContentRoutes(server, shareContentRuntime);
71
+ }
72
+
56
73
  console.log('Server starting on port ', environment.PORT || 3000);
57
74
  await server.listen({ host: '0.0.0.0', port: Number(environment.PORT || 3000) });
58
75
  } catch (err) {
76
+ if (shareContentRuntime?.enabled) {
77
+ await shareContentRuntime.close().catch(() => {});
78
+ }
79
+
59
80
  console.error(err);
60
81
  process.exit(1);
61
82
  }
@@ -0,0 +1,48 @@
1
+ import type { PresentationVerificationResult, PresentationVerifier, ReplayStore } from './types';
2
+
3
+ /**
4
+ * Thin, injectable adapters over the existing LearnCloud primitives.
5
+ *
6
+ * These are the seams the future service routes will wire. They are not
7
+ * registered anywhere in this batch: no router, no server entrypoint, no
8
+ * environment bootstrap.
9
+ */
10
+
11
+ /** Minimal structural view of `getEmptyLearnCard()` for presentation verification. */
12
+ export type LearnCardLike = {
13
+ invoke: {
14
+ verifyPresentation: (
15
+ token: string,
16
+ options: { proofFormat: 'jwt' }
17
+ ) => Promise<PresentationVerificationResult>;
18
+ };
19
+ };
20
+
21
+ /**
22
+ * Adapter for the existing DIDKit verification used by LearnCloud's context:
23
+ * `learnCard.invoke.verifyPresentation(jwt, { proofFormat: 'jwt' })`.
24
+ *
25
+ * This adapter is intentionally not constructed at import time and is not
26
+ * registered on any route in this batch.
27
+ */
28
+ export const createDidkitPresentationVerifier = (
29
+ getLearnCard: () => Promise<LearnCardLike>
30
+ ): PresentationVerifier => {
31
+ return async (token: string) => {
32
+ const learnCard = await getLearnCard();
33
+
34
+ return learnCard.invoke.verifyPresentation(token, { proofFormat: 'jwt' });
35
+ };
36
+ };
37
+
38
+ /**
39
+ * `SET key value NX EX ttl` style atomic primitive. Must resolve `true` only
40
+ * when the key was absent and this call created it, and must throw when the
41
+ * store is unavailable.
42
+ */
43
+ export type SetIfAbsent = (key: string, ttlSeconds: number) => Promise<boolean>;
44
+
45
+ /** Adapt an atomic set-if-absent primitive (e.g. Redis) into a `ReplayStore`. */
46
+ export const createSetIfAbsentReplayStore = (setIfAbsent: SetIfAbsent): ReplayStore => ({
47
+ consumeOnce: (key, ttlSeconds) => setIfAbsent(key, ttlSeconds),
48
+ });
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalizeShareContentRequestBody } from '@learncard/helpers';
3
+
4
+ import { SHARE_CONTENT_OPERATIONS, type ShareContentOperation } from './types';
5
+
6
+ /**
7
+ * Canonicalization / identifier primitives for the LC-2187 service auth module.
8
+ *
9
+ * Every value that reaches a hash or a comparison is validated here with strict
10
+ * (not permissive) rules. `Buffer.from(x, 'base64url')` is lenient, so the
11
+ * canonical base64url check re-encodes and requires an exact round trip, which
12
+ * rejects alternative spellings that merely decode to the same bytes.
13
+ */
14
+
15
+ export {
16
+ canonicalizeShareContentRequestBody,
17
+ DEFAULT_MAX_CANONICAL_BYTES as DEFAULT_MAX_CANONICAL_REQUEST_BYTES,
18
+ DEFAULT_MAX_CANONICAL_DEPTH,
19
+ ShareContentCanonicalizationError,
20
+ } from '@learncard/helpers';
21
+
22
+ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
23
+ const OPAQUE_IDENTIFIER_RE = /^[A-Za-z0-9._~-]+$/;
24
+ const JTI_RE = /^[A-Za-z0-9_-]+$/;
25
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
26
+
27
+ export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
28
+ if (typeof value !== 'object' || value === null) return false;
29
+
30
+ const prototype = Object.getPrototypeOf(value);
31
+
32
+ return prototype === Object.prototype || prototype === null;
33
+ };
34
+
35
+ /** Strict canonical base64url: alphabet, no padding, exact round trip, optional byte length. */
36
+ export const isCanonicalBase64Url = (value: unknown, expectedBytes?: number): value is string => {
37
+ if (typeof value !== 'string' || value.length === 0) return false;
38
+ if (!BASE64URL_RE.test(value)) return false;
39
+
40
+ const decoded = Buffer.from(value, 'base64url');
41
+
42
+ if (decoded.toString('base64url') !== value) return false;
43
+ if (expectedBytes !== undefined && decoded.length !== expectedBytes) return false;
44
+
45
+ return true;
46
+ };
47
+
48
+ export const isSafeContentVersion = (value: unknown): value is number =>
49
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 && value <= 2 ** 31 - 1;
50
+
51
+ export const isSafeEpochSecond = (value: unknown): value is number =>
52
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
53
+
54
+ /**
55
+ * Opaque profile/object/operation/namespace identifier. Deliberately not a UUID
56
+ * check: existing profile ids are opaque URL-safe strings.
57
+ */
58
+ export const isOpaqueIdentifier = (value: unknown, maxLength = 128): value is string =>
59
+ typeof value === 'string' &&
60
+ value.length >= 1 &&
61
+ value.length <= maxLength &&
62
+ OPAQUE_IDENTIFIER_RE.test(value);
63
+
64
+ export const isBoundedNonce = (
65
+ value: unknown,
66
+ minLength: number,
67
+ maxLength: number
68
+ ): value is string =>
69
+ typeof value === 'string' &&
70
+ value.length >= minLength &&
71
+ value.length <= maxLength &&
72
+ JTI_RE.test(value);
73
+
74
+ export const isSha256Hex = (value: unknown): value is string =>
75
+ typeof value === 'string' && SHA256_HEX_RE.test(value);
76
+
77
+ export const isShareContentOperation = (value: unknown): value is ShareContentOperation =>
78
+ typeof value === 'string' && (SHARE_CONTENT_OPERATIONS as readonly string[]).includes(value);
79
+
80
+ /**
81
+ * SHA-256 (lowercase hex) over the canonical UTF-8 request body. This is the
82
+ * value signed into `claims.requestHash`; it must include the opaque ciphertext
83
+ * envelope and the opaque owner-encrypted recovery for `put`.
84
+ */
85
+ export const computeShareContentRequestHash = (
86
+ body: unknown,
87
+ options: { maxBytes?: number; maxDepth?: number } = {}
88
+ ): string =>
89
+ createHash('sha256')
90
+ .update(canonicalizeShareContentRequestBody(body, options), 'utf8')
91
+ .digest('hex');
@@ -0,0 +1,164 @@
1
+ import {
2
+ isBoundedNonce,
3
+ isCanonicalBase64Url,
4
+ isOpaqueIdentifier,
5
+ isPlainObject,
6
+ isSafeContentVersion,
7
+ isSafeEpochSecond,
8
+ isSha256Hex,
9
+ isShareContentOperation,
10
+ } from './canonical';
11
+ import {
12
+ SHARE_CONTENT_AUTH_PURPOSE,
13
+ SHARE_CONTENT_CLAIM_FIELDS,
14
+ type ShareContentAuthorizationClaims,
15
+ type ShareContentTrustConfig,
16
+ } from './types';
17
+
18
+ /** Share ids are 16 CSPRNG bytes → exactly 22 canonical base64url chars. */
19
+ export const SHARE_ID_BYTES = 16;
20
+
21
+ const MAX_OPAQUE_IDENTIFIER_LENGTH = 128;
22
+
23
+ export type SignedClaimsResult =
24
+ { ok: true; raw: unknown } | { ok: false; reason: 'MALFORMED_CLAIMS' };
25
+
26
+ /**
27
+ * Locate the JSON claims the holder signed. DIDKit serializes the presentation
28
+ * proof `challenge` into the JWT `nonce`; some clients surface it under
29
+ * `vp.proof.challenge`. Exactly one location/value must be present.
30
+ */
31
+ export const extractSignedClaims = (payload: Record<string, unknown>): SignedClaimsResult => {
32
+ const nonce = payload.nonce;
33
+ const vp = payload.vp;
34
+
35
+ const proofChallenge =
36
+ isPlainObject(vp) && isPlainObject(vp.proof) ? vp.proof.challenge : undefined;
37
+
38
+ const candidates = [nonce, proofChallenge].filter(candidate => candidate !== undefined);
39
+
40
+ if (candidates.length === 0) return { ok: false, reason: 'MALFORMED_CLAIMS' };
41
+ if (candidates.length === 2 && candidates[0] !== candidates[1]) {
42
+ return { ok: false, reason: 'MALFORMED_CLAIMS' };
43
+ }
44
+
45
+ const challenge = candidates[0];
46
+
47
+ if (typeof challenge !== 'string' || challenge.length === 0) {
48
+ return { ok: false, reason: 'MALFORMED_CLAIMS' };
49
+ }
50
+
51
+ try {
52
+ return { ok: true, raw: JSON.parse(challenge) };
53
+ } catch {
54
+ return { ok: false, reason: 'MALFORMED_CLAIMS' };
55
+ }
56
+ };
57
+
58
+ export type ParseClaimsResult =
59
+ | { ok: true; claims: ShareContentAuthorizationClaims }
60
+ | {
61
+ ok: false;
62
+ reason:
63
+ | 'MALFORMED_CLAIMS'
64
+ | 'UNKNOWN_CLAIM_FIELD'
65
+ | 'INVALID_CLAIMS'
66
+ | 'INVALID_SHARE_ID'
67
+ | 'INVALID_IDENTIFIER'
68
+ | 'INVALID_JTI'
69
+ | 'OPERATION_NOT_ALLOWED'
70
+ | 'PURPOSE_MISMATCH';
71
+ };
72
+
73
+ /**
74
+ * Strict structural validation of the signed claims. Audience/purpose/time and
75
+ * request binding are checked separately by the verifier so each has a distinct
76
+ * rejection reason.
77
+ */
78
+ export const parseShareContentAuthorizationClaims = (
79
+ raw: unknown,
80
+ config: ShareContentTrustConfig
81
+ ): ParseClaimsResult => {
82
+ if (!isPlainObject(raw)) return { ok: false, reason: 'MALFORMED_CLAIMS' };
83
+
84
+ for (const key of Object.keys(raw)) {
85
+ if (!(SHARE_CONTENT_CLAIM_FIELDS as readonly string[]).includes(key)) {
86
+ return { ok: false, reason: 'UNKNOWN_CLAIM_FIELD' };
87
+ }
88
+ }
89
+
90
+ const {
91
+ iss,
92
+ aud,
93
+ purpose,
94
+ namespace,
95
+ op,
96
+ shareId,
97
+ contentVersion,
98
+ ownerProfileId,
99
+ objectId,
100
+ operationId,
101
+ requestHash,
102
+ iat,
103
+ exp,
104
+ jti,
105
+ } = raw;
106
+
107
+ if (typeof iss !== 'string' || iss.length === 0) return { ok: false, reason: 'INVALID_CLAIMS' };
108
+ if (typeof aud !== 'string' || aud.length === 0) return { ok: false, reason: 'INVALID_CLAIMS' };
109
+ if (purpose !== SHARE_CONTENT_AUTH_PURPOSE) return { ok: false, reason: 'PURPOSE_MISMATCH' };
110
+
111
+ if (!isOpaqueIdentifier(namespace, MAX_OPAQUE_IDENTIFIER_LENGTH)) {
112
+ return { ok: false, reason: 'INVALID_IDENTIFIER' };
113
+ }
114
+
115
+ if (!isShareContentOperation(op)) return { ok: false, reason: 'OPERATION_NOT_ALLOWED' };
116
+
117
+ if (!isCanonicalBase64Url(shareId, SHARE_ID_BYTES)) {
118
+ return { ok: false, reason: 'INVALID_SHARE_ID' };
119
+ }
120
+
121
+ if (!isSafeContentVersion(contentVersion)) return { ok: false, reason: 'INVALID_CLAIMS' };
122
+
123
+ if (!isOpaqueIdentifier(ownerProfileId, MAX_OPAQUE_IDENTIFIER_LENGTH)) {
124
+ return { ok: false, reason: 'INVALID_IDENTIFIER' };
125
+ }
126
+
127
+ if (!isOpaqueIdentifier(objectId, MAX_OPAQUE_IDENTIFIER_LENGTH)) {
128
+ return { ok: false, reason: 'INVALID_IDENTIFIER' };
129
+ }
130
+
131
+ if (!isOpaqueIdentifier(operationId, MAX_OPAQUE_IDENTIFIER_LENGTH)) {
132
+ return { ok: false, reason: 'INVALID_IDENTIFIER' };
133
+ }
134
+
135
+ if (!isSha256Hex(requestHash)) return { ok: false, reason: 'INVALID_CLAIMS' };
136
+
137
+ if (!isSafeEpochSecond(iat) || !isSafeEpochSecond(exp)) {
138
+ return { ok: false, reason: 'INVALID_CLAIMS' };
139
+ }
140
+
141
+ if (!isBoundedNonce(jti, config.minJtiLength, config.maxJtiLength)) {
142
+ return { ok: false, reason: 'INVALID_JTI' };
143
+ }
144
+
145
+ return {
146
+ ok: true,
147
+ claims: {
148
+ iss,
149
+ aud,
150
+ purpose: SHARE_CONTENT_AUTH_PURPOSE,
151
+ namespace,
152
+ op,
153
+ shareId,
154
+ contentVersion,
155
+ ownerProfileId,
156
+ objectId,
157
+ operationId,
158
+ requestHash,
159
+ iat,
160
+ exp,
161
+ jti,
162
+ },
163
+ };
164
+ };
@@ -0,0 +1,160 @@
1
+ import type { ShareContentTrustConfig } from './types';
2
+
3
+ /**
4
+ * Build the trust configuration from an explicit, caller-supplied object.
5
+ *
6
+ * Nothing here reads `process.env`, secrets or a bootstrap file: the batch that
7
+ * owns environment wiring must construct this deliberately and pass it in. Any
8
+ * missing or invalid value when `enabled` is true collapses to the disabled
9
+ * configuration so access fails closed rather than partially.
10
+ */
11
+
12
+ export const HARD_MAX_TOKEN_TTL_SECONDS = 60;
13
+ export const HARD_MAX_CLOCK_SKEW_SECONDS = 60;
14
+ export const HARD_MIN_TOKEN_BYTES = 256;
15
+ export const HARD_MAX_TOKEN_BYTES = 65_536;
16
+ export const DEFAULT_MAX_TOKEN_BYTES = 8_192;
17
+ export const DEFAULT_MIN_JTI_LENGTH = 16;
18
+ export const DEFAULT_MAX_JTI_LENGTH = 128;
19
+
20
+ export const DISABLED_SHARE_CONTENT_TRUST_CONFIG: ShareContentTrustConfig = Object.freeze({
21
+ enabled: false,
22
+ audience: '',
23
+ allowedServiceDids: Object.freeze([]) as readonly string[],
24
+ allowedVerificationMethods: Object.freeze([]) as readonly string[],
25
+ allowedAlgorithms: Object.freeze(['EdDSA']) as readonly string[],
26
+ clockSkewSeconds: 5,
27
+ maxTokenTtlSeconds: HARD_MAX_TOKEN_TTL_SECONDS,
28
+ maxTokenBytes: DEFAULT_MAX_TOKEN_BYTES,
29
+ minJtiLength: DEFAULT_MIN_JTI_LENGTH,
30
+ maxJtiLength: DEFAULT_MAX_JTI_LENGTH,
31
+ });
32
+
33
+ const toStringList = (value: unknown): string[] | null => {
34
+ if (typeof value === 'string') {
35
+ const parts = value
36
+ .split(',')
37
+ .map(part => part.trim())
38
+ .filter(part => part.length > 0);
39
+
40
+ return parts;
41
+ }
42
+
43
+ if (Array.isArray(value)) {
44
+ if (!value.every(item => typeof item === 'string')) return null;
45
+
46
+ const parts = (value as string[]).map(part => part.trim()).filter(part => part.length > 0);
47
+
48
+ return parts;
49
+ }
50
+
51
+ return null;
52
+ };
53
+
54
+ const readBoolean = (value: unknown): boolean => value === true;
55
+
56
+ const readBoundedInteger = (
57
+ value: unknown,
58
+ fallback: number,
59
+ minimum: number,
60
+ maximum: number
61
+ ): number | null => {
62
+ if (value === undefined) return fallback;
63
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) return null;
64
+ if (value < minimum || value > maximum) return null;
65
+
66
+ return value;
67
+ };
68
+
69
+ /**
70
+ * Resolve explicit configuration. Returns the frozen, fully-validated config or
71
+ * the disabled config. An empty allowlist, empty audience or `enabled: false`
72
+ * disables all access.
73
+ */
74
+ export const resolveShareContentTrustConfig = (raw: unknown): ShareContentTrustConfig => {
75
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
76
+ return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
77
+ }
78
+
79
+ const source = raw as Record<string, unknown>;
80
+
81
+ if (!readBoolean(source.enabled)) return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
82
+
83
+ const audience = source.audience;
84
+ if (typeof audience !== 'string' || audience.trim().length === 0) {
85
+ return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
86
+ }
87
+
88
+ const serviceDids = toStringList(source.serviceDids);
89
+ const verificationMethods = toStringList(source.verificationMethods);
90
+
91
+ if (!serviceDids || serviceDids.length === 0) return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
92
+ if (!verificationMethods || verificationMethods.length === 0) {
93
+ return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
94
+ }
95
+
96
+ const allowedAlgorithms = toStringList(source.allowedAlgorithms) ?? ['EdDSA'];
97
+ if (allowedAlgorithms.length === 0) return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
98
+
99
+ const clockSkewSeconds = readBoundedInteger(
100
+ source.clockSkewSeconds,
101
+ DISABLED_SHARE_CONTENT_TRUST_CONFIG.clockSkewSeconds,
102
+ 0,
103
+ HARD_MAX_CLOCK_SKEW_SECONDS
104
+ );
105
+ const maxTokenTtlSeconds = readBoundedInteger(
106
+ source.maxTokenTtlSeconds,
107
+ HARD_MAX_TOKEN_TTL_SECONDS,
108
+ 1,
109
+ HARD_MAX_TOKEN_TTL_SECONDS
110
+ );
111
+ const maxTokenBytes = readBoundedInteger(
112
+ source.maxTokenBytes,
113
+ DEFAULT_MAX_TOKEN_BYTES,
114
+ HARD_MIN_TOKEN_BYTES,
115
+ HARD_MAX_TOKEN_BYTES
116
+ );
117
+ const minJtiLength = readBoundedInteger(
118
+ source.minJtiLength,
119
+ DEFAULT_MIN_JTI_LENGTH,
120
+ 8,
121
+ DEFAULT_MAX_JTI_LENGTH
122
+ );
123
+ const maxJtiLength = readBoundedInteger(
124
+ source.maxJtiLength,
125
+ DEFAULT_MAX_JTI_LENGTH,
126
+ DEFAULT_MIN_JTI_LENGTH,
127
+ 512
128
+ );
129
+
130
+ if (
131
+ clockSkewSeconds === null ||
132
+ maxTokenTtlSeconds === null ||
133
+ maxTokenBytes === null ||
134
+ minJtiLength === null ||
135
+ maxJtiLength === null ||
136
+ minJtiLength > maxJtiLength
137
+ ) {
138
+ return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
139
+ }
140
+
141
+ return Object.freeze({
142
+ enabled: true,
143
+ audience,
144
+ allowedServiceDids: Object.freeze([...serviceDids]),
145
+ allowedVerificationMethods: Object.freeze([...verificationMethods]),
146
+ allowedAlgorithms: Object.freeze([...allowedAlgorithms]),
147
+ clockSkewSeconds,
148
+ maxTokenTtlSeconds,
149
+ maxTokenBytes,
150
+ minJtiLength,
151
+ maxJtiLength,
152
+ });
153
+ };
154
+
155
+ /** A config only authorizes traffic when it is enabled and its allowlists are non-empty. */
156
+ export const isShareContentTrustConfigActive = (config: ShareContentTrustConfig): boolean =>
157
+ config.enabled &&
158
+ config.audience.length > 0 &&
159
+ config.allowedServiceDids.length > 0 &&
160
+ config.allowedVerificationMethods.length > 0;