@technomoron/api-server-base 2.0.0-beta.22 → 2.0.0-beta.24

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 (114) hide show
  1. package/dist/cjs/api-module.cjs +8 -0
  2. package/dist/cjs/api-module.d.ts +12 -0
  3. package/dist/cjs/api-server-base.cjs +573 -615
  4. package/dist/cjs/api-server-base.d.ts +97 -87
  5. package/dist/cjs/auth-api/{auth-module.js → auth-module.cjs} +96 -76
  6. package/dist/cjs/auth-api/auth-module.d.ts +1 -1
  7. package/dist/cjs/auth-api/{compat-auth-storage.js → compat-auth-storage.cjs} +4 -4
  8. package/dist/cjs/auth-api/{mem-auth-store.js → mem-auth-store.cjs} +7 -7
  9. package/dist/cjs/auth-api/{module.js → module.cjs} +1 -1
  10. package/dist/cjs/auth-api/schemas.cjs +171 -0
  11. package/dist/cjs/auth-api/schemas.d.ts +21 -0
  12. package/dist/cjs/auth-api/{sql-auth-store.js → sql-auth-store.cjs} +8 -8
  13. package/dist/cjs/auth-api/{user-id.js → user-id.cjs} +12 -3
  14. package/dist/cjs/auth-cookie-options.d.ts +5 -3
  15. package/dist/cjs/base/client-info.cjs +285 -0
  16. package/dist/cjs/base/client-info.d.ts +27 -0
  17. package/dist/cjs/base/error-utils.cjs +50 -0
  18. package/dist/cjs/base/error-utils.d.ts +16 -0
  19. package/dist/cjs/base/request-utils.cjs +27 -0
  20. package/dist/cjs/base/request-utils.d.ts +8 -0
  21. package/dist/cjs/index.cjs +24 -15
  22. package/dist/cjs/index.d.ts +7 -0
  23. package/dist/cjs/limiter/auth-rate-limiter.cjs +35 -0
  24. package/dist/cjs/limiter/auth-rate-limiter.d.ts +12 -0
  25. package/dist/cjs/limiter/fixed-window.cjs +41 -0
  26. package/dist/cjs/limiter/fixed-window.d.ts +11 -0
  27. package/dist/cjs/oauth/{base.js → base.cjs} +1 -0
  28. package/dist/cjs/oauth/base.d.ts +8 -1
  29. package/dist/cjs/oauth/{memory.js → memory.cjs} +7 -4
  30. package/dist/cjs/oauth/memory.d.ts +1 -1
  31. package/dist/cjs/oauth/{models.js → models.cjs} +2 -2
  32. package/dist/cjs/oauth/{sequelize.js → sequelize.cjs} +11 -7
  33. package/dist/cjs/oauth/sequelize.d.ts +1 -1
  34. package/dist/cjs/passkey/{base.js → base.cjs} +1 -0
  35. package/dist/cjs/passkey/base.d.ts +11 -0
  36. package/dist/cjs/passkey/{memory.js → memory.cjs} +2 -2
  37. package/dist/cjs/passkey/{models.js → models.cjs} +1 -1
  38. package/dist/cjs/passkey/{sequelize.js → sequelize.cjs} +3 -3
  39. package/dist/cjs/passkey/{service.js → service.cjs} +17 -3
  40. package/dist/cjs/passkey/service.d.ts +1 -1
  41. package/dist/cjs/{sequelize-utils.js → sequelize-utils.cjs} +4 -5
  42. package/dist/cjs/token/{base.js → base.cjs} +4 -0
  43. package/dist/cjs/token/base.d.ts +7 -0
  44. package/dist/cjs/token/{memory.js → memory.cjs} +15 -20
  45. package/dist/cjs/token/{sequelize.js → sequelize.cjs} +25 -11
  46. package/dist/cjs/upload/memory.cjs +92 -0
  47. package/dist/cjs/upload/memory.d.ts +17 -0
  48. package/dist/cjs/upload/tus-module.cjs +270 -0
  49. package/dist/cjs/upload/tus-module.d.ts +38 -0
  50. package/dist/cjs/upload/types.d.ts +28 -0
  51. package/dist/cjs/user/{base.js → base.cjs} +1 -0
  52. package/dist/cjs/user/base.d.ts +9 -0
  53. package/dist/cjs/user/{memory.js → memory.cjs} +29 -7
  54. package/dist/cjs/user/{sequelize.js → sequelize.cjs} +33 -8
  55. package/dist/cjs/user/types.cjs +2 -0
  56. package/dist/esm/api-module.d.ts +12 -0
  57. package/dist/esm/api-module.js +8 -0
  58. package/dist/esm/api-server-base.d.ts +97 -87
  59. package/dist/esm/api-server-base.js +562 -604
  60. package/dist/esm/auth-api/auth-module.d.ts +1 -1
  61. package/dist/esm/auth-api/auth-module.js +92 -72
  62. package/dist/esm/auth-api/compat-auth-storage.js +3 -3
  63. package/dist/esm/auth-api/schemas.d.ts +21 -0
  64. package/dist/esm/auth-api/schemas.js +168 -0
  65. package/dist/esm/auth-api/user-id.js +12 -3
  66. package/dist/esm/auth-cookie-options.d.ts +5 -3
  67. package/dist/esm/base/client-info.d.ts +27 -0
  68. package/dist/esm/base/client-info.js +282 -0
  69. package/dist/esm/base/error-utils.d.ts +16 -0
  70. package/dist/esm/base/error-utils.js +44 -0
  71. package/dist/esm/base/request-utils.d.ts +8 -0
  72. package/dist/esm/base/request-utils.js +23 -0
  73. package/dist/esm/index.d.ts +7 -0
  74. package/dist/esm/index.js +4 -0
  75. package/dist/esm/limiter/auth-rate-limiter.d.ts +12 -0
  76. package/dist/esm/limiter/auth-rate-limiter.js +32 -0
  77. package/dist/esm/limiter/fixed-window.d.ts +11 -0
  78. package/dist/esm/limiter/fixed-window.js +37 -0
  79. package/dist/esm/oauth/base.d.ts +8 -1
  80. package/dist/esm/oauth/base.js +1 -0
  81. package/dist/esm/oauth/memory.d.ts +1 -1
  82. package/dist/esm/oauth/memory.js +5 -2
  83. package/dist/esm/oauth/sequelize.d.ts +1 -1
  84. package/dist/esm/oauth/sequelize.js +6 -2
  85. package/dist/esm/passkey/base.d.ts +11 -0
  86. package/dist/esm/passkey/base.js +1 -0
  87. package/dist/esm/passkey/service.d.ts +1 -1
  88. package/dist/esm/passkey/service.js +17 -3
  89. package/dist/esm/sequelize-utils.js +4 -5
  90. package/dist/esm/token/base.d.ts +7 -0
  91. package/dist/esm/token/base.js +4 -0
  92. package/dist/esm/token/memory.js +14 -19
  93. package/dist/esm/token/sequelize.js +22 -8
  94. package/dist/esm/upload/memory.d.ts +17 -0
  95. package/dist/esm/upload/memory.js +86 -0
  96. package/dist/esm/upload/tus-module.d.ts +38 -0
  97. package/dist/esm/upload/tus-module.js +266 -0
  98. package/dist/esm/upload/types.d.ts +28 -0
  99. package/dist/esm/upload/types.js +1 -0
  100. package/dist/esm/user/base.d.ts +9 -0
  101. package/dist/esm/user/base.js +1 -0
  102. package/dist/esm/user/memory.js +27 -5
  103. package/dist/esm/user/sequelize.js +30 -5
  104. package/docs/swagger/openapi.json +1 -1
  105. package/package.json +18 -17
  106. package/README.txt +0 -216
  107. /package/dist/cjs/auth-api/{storage.js → storage.cjs} +0 -0
  108. /package/dist/cjs/auth-api/{types.js → types.cjs} +0 -0
  109. /package/dist/cjs/{auth-cookie-options.js → auth-cookie-options.cjs} +0 -0
  110. /package/dist/cjs/oauth/{types.js → types.cjs} +0 -0
  111. /package/dist/cjs/passkey/{config.js → config.cjs} +0 -0
  112. /package/dist/cjs/passkey/{types.js → types.cjs} +0 -0
  113. /package/dist/cjs/token/{types.js → types.cjs} +0 -0
  114. /package/dist/cjs/{user/types.js → upload/types.cjs} +0 -0
@@ -8,7 +8,7 @@ export declare class PasskeyService {
8
8
  private readonly logger;
9
9
  constructor(config: PasskeyServiceConfig, adapter: PasskeyStorageAdapter, logger?: Logger);
10
10
  listUserCredentials(userId: AuthIdentifier): Promise<StoredPasskeyCredential[]>;
11
- deleteCredential(credentialId: Buffer | string): Promise<boolean>;
11
+ deleteCredential(credentialId: Buffer | string, userId?: AuthIdentifier): Promise<boolean>;
12
12
  createChallenge(params: PasskeyChallengeParams): Promise<PasskeyChallenge>;
13
13
  verifyResponse(params: PasskeyVerificationParams): Promise<PasskeyVerificationResult>;
14
14
  private createRegistrationChallenge;
@@ -125,7 +125,19 @@ export class PasskeyService {
125
125
  async listUserCredentials(userId) {
126
126
  return this.adapter.listUserCredentials(userId);
127
127
  }
128
- async deleteCredential(credentialId) {
128
+ async deleteCredential(credentialId, userId) {
129
+ if (userId !== undefined) {
130
+ const credentials = await this.adapter.listUserCredentials(userId);
131
+ const target = Buffer.isBuffer(credentialId) ? credentialId : Buffer.from(String(credentialId), 'base64');
132
+ const owns = credentials.some((c) => {
133
+ const stored = Buffer.isBuffer(c.credentialId)
134
+ ? c.credentialId
135
+ : Buffer.from(String(c.credentialId), 'base64');
136
+ return stored.equals(target);
137
+ });
138
+ if (!owns)
139
+ return false;
140
+ }
129
141
  return this.adapter.deleteCredential(credentialId);
130
142
  }
131
143
  async createChallenge(params) {
@@ -251,7 +263,9 @@ export class PasskeyService {
251
263
  const attestationResponse = params.response.response;
252
264
  const credentialIdPrimary = toBufferOrNull(registrationInfo.credentialID);
253
265
  const credentialIdFallback = toBufferOrNull(params.response.id);
254
- const credentialId = credentialIdPrimary && credentialIdPrimary.length > 0 ? credentialIdPrimary : credentialIdFallback;
266
+ const credentialId = credentialIdPrimary && credentialIdPrimary.length > 0
267
+ ? credentialIdPrimary
268
+ : (credentialIdFallback ?? Buffer.alloc(0));
255
269
  const publicKeyPrimary = toBufferOrNull(registrationInfo.credentialPublicKey);
256
270
  let publicKeyFallback = toBufferOrNull(attestationResponse?.publicKey);
257
271
  if ((!publicKeyPrimary || publicKeyPrimary.length === 0) && attestationResponse?.attestationObject) {
@@ -294,7 +308,7 @@ export class PasskeyService {
294
308
  publicKey: storedPublicKey,
295
309
  counter: registrationInfo.counter ?? 0,
296
310
  transports: sanitizeTransports(params.response.transports),
297
- backedUp: registrationInfo.credentialDeviceType === 'multiDevice',
311
+ backedUp: registrationInfo.credentialBackedUp ?? registrationInfo.credentialDeviceType === 'multiDevice',
298
312
  deviceType: registrationInfo.credentialDeviceType,
299
313
  label: toOptionalString(params.label),
300
314
  createdDomain: toOptionalString(params.domain),
@@ -39,10 +39,9 @@ export function decodeStringArray(raw) {
39
39
  }
40
40
  }
41
41
  catch {
42
- // ignore malformed values
42
+ // Malformed JSON — return empty rather than guessing via whitespace split.
43
+ // A whitespace-split fallback could silently grant unintended permissions
44
+ // if a scope/role column contains a corrupted value like "admin user".
43
45
  }
44
- return raw
45
- .split(/\s+/)
46
- .map((entry) => entry.trim())
47
- .filter((entry) => entry.length > 0);
46
+ return [];
48
47
  }
@@ -17,20 +17,27 @@ export interface JwtDecodeResult<T> {
17
17
  data?: T;
18
18
  error?: string;
19
19
  }
20
+ /** Base contract for token/session persistence backends plus shared JWT helpers. */
20
21
  export declare abstract class TokenStore {
22
+ /** Create/persist a token record. */
21
23
  abstract save(record: Token): Promise<void>;
24
+ /** Read a token record by partial query. */
22
25
  abstract get(query: Partial<Token>, opts?: {
23
26
  includeExpired?: boolean;
24
27
  }): Promise<Token | null>;
28
+ /** Delete token records matching a partial query. */
25
29
  abstract delete(query: Partial<Token>): Promise<number>;
30
+ /** Update a token identified by refresh token. */
26
31
  abstract update(update: Partial<Token> & {
27
32
  refreshToken: string;
28
33
  }): Promise<boolean>;
34
+ /** List tokens for a specific user. */
29
35
  abstract list(userId: string | number, opts?: {
30
36
  limit?: number;
31
37
  offset?: number;
32
38
  includeExpired?: boolean;
33
39
  }): Promise<Token[]>;
40
+ /** Close underlying resources. */
34
41
  abstract close(): Promise<void>;
35
42
  normalizeToken(token: Partial<Token>): Token;
36
43
  jwtSign(payload: JwtSignPayload, secret: string, expiresInSeconds: number, options?: SignOptions): JwtSignResult;
@@ -36,6 +36,9 @@ function normalizeTokenInternal(input) {
36
36
  const userId = String(input.userId);
37
37
  const ruid = input.ruid === undefined || input.ruid === null ? undefined : String(input.ruid);
38
38
  const expires = input.expires ? new Date(input.expires) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
39
+ if (Number.isNaN(expires.getTime())) {
40
+ throw new Error(`Invalid token expiry value: ${String(input.expires)}`);
41
+ }
39
42
  const issuedAt = input.issuedAt ? new Date(input.issuedAt) : new Date();
40
43
  const lastSeenAt = input.lastSeenAt ? new Date(input.lastSeenAt) : issuedAt;
41
44
  const scope = normalizeScope(input.scope);
@@ -65,6 +68,7 @@ function normalizeTokenInternal(input) {
65
68
  sessionCookie
66
69
  };
67
70
  }
71
+ /** Base contract for token/session persistence backends plus shared JWT helpers. */
68
72
  export class TokenStore {
69
73
  // Instance helpers
70
74
  normalizeToken(token) {
@@ -15,16 +15,16 @@ function cloneToken(record) {
15
15
  };
16
16
  }
17
17
  function matchesQuery(record, query, includeExpired) {
18
- if (query.refreshToken && record.refreshToken !== query.refreshToken) {
18
+ if (query.refreshToken !== undefined && record.refreshToken !== query.refreshToken) {
19
19
  return false;
20
20
  }
21
- if (query.accessToken && record.accessToken !== query.accessToken) {
21
+ if (query.accessToken !== undefined && record.accessToken !== query.accessToken) {
22
22
  return false;
23
23
  }
24
24
  if (query.userId !== undefined && comparableUserId(record.userId) !== comparableUserId(query.userId)) {
25
25
  return false;
26
26
  }
27
- if (query.clientId && record.clientId !== query.clientId) {
27
+ if (query.clientId !== undefined && record.clientId !== query.clientId) {
28
28
  return false;
29
29
  }
30
30
  if (query.domain !== undefined && (record.domain ?? '') !== (query.domain ?? '')) {
@@ -36,11 +36,14 @@ function matchesQuery(record, query, includeExpired) {
36
36
  if (query.loginType !== undefined && record.loginType !== (query.loginType ?? undefined)) {
37
37
  return false;
38
38
  }
39
- if (query.label && record.label !== query.label) {
39
+ if (query.label !== undefined && record.label !== query.label) {
40
40
  return false;
41
41
  }
42
- if (!includeExpired && (record.expires ?? new Date(0)).getTime() < Date.now()) {
43
- return false;
42
+ if (!includeExpired) {
43
+ const expiresTime = record.expires ? record.expires.getTime() : Infinity;
44
+ if (expiresTime < Date.now()) {
45
+ return false;
46
+ }
44
47
  }
45
48
  return true;
46
49
  }
@@ -163,16 +166,14 @@ export class MemoryTokenStore extends TokenStore {
163
166
  const merged = { ...token };
164
167
  const maybeAssign = (key) => {
165
168
  const value = params[key];
166
- if (value !== undefined) {
169
+ if (value !== undefined && value !== null) {
167
170
  merged[key] = value;
168
171
  }
169
172
  };
170
- if (params.accessToken !== undefined && params.accessToken !== null) {
171
- merged.accessToken = params.accessToken;
172
- }
173
- if (params.expires !== undefined && params.expires !== null) {
174
- merged.expires = params.expires;
175
- }
173
+ maybeAssign('accessToken');
174
+ maybeAssign('expires');
175
+ maybeAssign('issuedAt');
176
+ maybeAssign('lastSeenAt');
176
177
  maybeAssign('scope');
177
178
  maybeAssign('label');
178
179
  maybeAssign('domain');
@@ -183,12 +184,6 @@ export class MemoryTokenStore extends TokenStore {
183
184
  maybeAssign('os');
184
185
  maybeAssign('refreshTtlSeconds');
185
186
  maybeAssign('loginType');
186
- if (params.issuedAt !== undefined && params.issuedAt !== null) {
187
- merged.issuedAt = params.issuedAt;
188
- }
189
- if (params.lastSeenAt !== undefined && params.lastSeenAt !== null) {
190
- merged.lastSeenAt = params.lastSeenAt;
191
- }
192
187
  maybeAssign('sessionCookie');
193
188
  const normalized = this.normalizeToken(merged);
194
189
  const previousUserId = token.userId;
@@ -164,12 +164,20 @@ export class SequelizeTokenStore extends TokenStore {
164
164
  await this.Tokens.destroy({ where: removalWhere, transaction });
165
165
  // Access/refresh columns are unique. Remove stale collisions before insert to avoid
166
166
  // transient uniqueness failures during retries/rotation edge-cases.
167
- await this.Tokens.destroy({
168
- where: {
169
- [Op.or]: [{ access: normalized.accessToken ?? '' }, { refresh: normalized.refreshToken }]
170
- },
171
- transaction
172
- });
167
+ // Only include non-empty token values to prevent matching unrelated rows.
168
+ const collisionConditions = [];
169
+ if (normalized.accessToken) {
170
+ collisionConditions.push({ access: normalized.accessToken });
171
+ }
172
+ if (normalized.refreshToken) {
173
+ collisionConditions.push({ refresh: normalized.refreshToken });
174
+ }
175
+ if (collisionConditions.length > 0) {
176
+ await this.Tokens.destroy({
177
+ where: { [Op.or]: collisionConditions },
178
+ transaction
179
+ });
180
+ }
173
181
  await this.Tokens.create({
174
182
  user_id: resolvedUserId,
175
183
  real_user_id: resolvedRealUserId,
@@ -310,8 +318,14 @@ export class SequelizeTokenStore extends TokenStore {
310
318
  if (Object.keys(updates).length === 0) {
311
319
  return false;
312
320
  }
313
- const [updated] = await this.Tokens.update(updates, { where });
314
- return updated > 0;
321
+ const sequelize = this.Tokens.sequelize;
322
+ if (!sequelize) {
323
+ throw new Error('Token model is not bound to a Sequelize instance');
324
+ }
325
+ return sequelize.transaction(async (transaction) => {
326
+ const [updated] = await this.Tokens.update(updates, { where, transaction });
327
+ return updated > 0;
328
+ });
315
329
  }
316
330
  async list(userId, opts = {}) {
317
331
  const where = { user_id: this.normalizeUserId(userId) };
@@ -0,0 +1,17 @@
1
+ import type { TusAppendInput, TusCreateUploadInput, TusUploadRecord, TusUploadStore } from './types.js';
2
+ export declare class TusUploadOffsetError extends Error {
3
+ readonly currentOffset: number;
4
+ constructor(currentOffset: number);
5
+ }
6
+ export declare class TusUploadExceedsLengthError extends Error {
7
+ constructor();
8
+ }
9
+ export declare class MemoryTusUploadStore implements TusUploadStore {
10
+ private readonly uploads;
11
+ private readonly chunks;
12
+ createUpload(input: TusCreateUploadInput): Promise<TusUploadRecord>;
13
+ getUpload(uploadId: string): Promise<TusUploadRecord | null>;
14
+ appendUpload(input: TusAppendInput): Promise<TusUploadRecord>;
15
+ deleteUpload(uploadId: string): Promise<boolean>;
16
+ readUpload(uploadId: string): Buffer | null;
17
+ }
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ function cloneRecord(record) {
3
+ return {
4
+ ...record,
5
+ metadata: { ...record.metadata },
6
+ createdAt: new Date(record.createdAt),
7
+ updatedAt: new Date(record.updatedAt),
8
+ ...(record.completedAt ? { completedAt: new Date(record.completedAt) } : {})
9
+ };
10
+ }
11
+ export class TusUploadOffsetError extends Error {
12
+ constructor(currentOffset) {
13
+ super('Upload offset does not match current offset');
14
+ this.currentOffset = currentOffset;
15
+ }
16
+ }
17
+ export class TusUploadExceedsLengthError extends Error {
18
+ constructor() {
19
+ super('Upload exceeds declared length');
20
+ }
21
+ }
22
+ export class MemoryTusUploadStore {
23
+ constructor() {
24
+ this.uploads = new Map();
25
+ this.chunks = new Map();
26
+ }
27
+ async createUpload(input) {
28
+ const id = input.id?.trim() || randomUUID();
29
+ if (this.uploads.has(id)) {
30
+ throw new Error(`Upload ${id} already exists`);
31
+ }
32
+ const now = new Date();
33
+ const record = {
34
+ id,
35
+ length: Math.max(0, Math.floor(input.length)),
36
+ offset: 0,
37
+ metadata: { ...input.metadata },
38
+ ...(input.userId ? { userId: input.userId } : {}),
39
+ createdAt: now,
40
+ updatedAt: now
41
+ };
42
+ this.uploads.set(id, record);
43
+ this.chunks.set(id, []);
44
+ return cloneRecord(record);
45
+ }
46
+ async getUpload(uploadId) {
47
+ const found = this.uploads.get(uploadId);
48
+ return found ? cloneRecord(found) : null;
49
+ }
50
+ async appendUpload(input) {
51
+ const current = this.uploads.get(input.uploadId);
52
+ if (!current) {
53
+ throw new Error('Upload not found');
54
+ }
55
+ if (input.offset !== current.offset) {
56
+ throw new TusUploadOffsetError(current.offset);
57
+ }
58
+ const nextOffset = current.offset + input.chunk.length;
59
+ if (nextOffset > current.length) {
60
+ throw new TusUploadExceedsLengthError();
61
+ }
62
+ const now = new Date();
63
+ const updated = {
64
+ ...current,
65
+ offset: nextOffset,
66
+ updatedAt: now,
67
+ ...(nextOffset === current.length ? { completedAt: now } : {})
68
+ };
69
+ this.uploads.set(input.uploadId, updated);
70
+ if (input.chunk.length > 0) {
71
+ this.chunks.get(input.uploadId)?.push(Buffer.from(input.chunk));
72
+ }
73
+ return cloneRecord(updated);
74
+ }
75
+ async deleteUpload(uploadId) {
76
+ this.chunks.delete(uploadId);
77
+ return this.uploads.delete(uploadId);
78
+ }
79
+ readUpload(uploadId) {
80
+ const chunks = this.chunks.get(uploadId);
81
+ if (!chunks) {
82
+ return null;
83
+ }
84
+ return Buffer.concat(chunks);
85
+ }
86
+ }
@@ -0,0 +1,38 @@
1
+ import { ApiModule, type ApiAuthClass, type ApiAuthType, type ApiServer } from '../api-server-base.js';
2
+ import type { TusUploadRecord, TusUploadStore } from './types.js';
3
+ export interface TusUploadModuleOptions {
4
+ basePath?: string;
5
+ store?: TusUploadStore;
6
+ chunkMaxBytes?: number;
7
+ /** Maximum allowed value for Upload-Length. Defaults to 10 GiB. */
8
+ uploadMaxBytes?: number;
9
+ /**
10
+ * Authentication requirement for all TUS routes.
11
+ * When omitted, routes are public (no authentication enforced).
12
+ * Use `{ type: 'yes', req: 'any' }` to require a valid session.
13
+ */
14
+ auth?: {
15
+ type: ApiAuthType;
16
+ req?: ApiAuthClass;
17
+ };
18
+ onUploadComplete?: (upload: TusUploadRecord) => Promise<void> | void;
19
+ }
20
+ export declare class TusUploadModule extends ApiModule<ApiServer> {
21
+ private readonly basePath;
22
+ private readonly chunkMaxBytes;
23
+ private readonly uploadMaxBytes;
24
+ private readonly store;
25
+ private readonly auth?;
26
+ private readonly onUploadComplete?;
27
+ constructor(options?: TusUploadModuleOptions);
28
+ onMount(): void;
29
+ /**
30
+ * Authenticate the request if an auth config was provided.
31
+ * Returns false and sends a 401/403 response if auth fails, so the caller
32
+ * can bail out early with `if (!await this.checkAuth(request, reply)) return`.
33
+ */
34
+ private checkAuth;
35
+ private authFailed;
36
+ private verifyOwnership;
37
+ private installTusRoutes;
38
+ }
@@ -0,0 +1,266 @@
1
+ import { ApiError, ApiModule } from '../api-server-base.js';
2
+ import { MemoryTusUploadStore, TusUploadExceedsLengthError, TusUploadOffsetError } from './memory.js';
3
+ const TUS_VERSION = '1.0.0';
4
+ function parseNonNegativeInt(raw, headerName) {
5
+ const value = Array.isArray(raw) ? raw[0] : raw;
6
+ const parsed = Number.parseInt(String(value ?? ''), 10);
7
+ if (!Number.isFinite(parsed) || parsed < 0) {
8
+ throw new ApiError({ code: 400, message: `Missing or invalid ${headerName} header` });
9
+ }
10
+ return parsed;
11
+ }
12
+ function parseUploadLength(raw) {
13
+ return parseNonNegativeInt(raw, 'Upload-Length');
14
+ }
15
+ function parseOffset(raw) {
16
+ return parseNonNegativeInt(raw, 'Upload-Offset');
17
+ }
18
+ function decodeMetadata(raw) {
19
+ const source = Array.isArray(raw) ? raw[0] : raw;
20
+ const input = String(source ?? '').trim();
21
+ if (!input) {
22
+ return {};
23
+ }
24
+ const out = {};
25
+ for (const pair of input.split(',')) {
26
+ const trimmed = pair.trim();
27
+ if (!trimmed) {
28
+ continue;
29
+ }
30
+ const [key, encoded = ''] = trimmed.split(' ');
31
+ if (!key) {
32
+ continue;
33
+ }
34
+ try {
35
+ out[key] = Buffer.from(encoded, 'base64').toString('utf8');
36
+ }
37
+ catch {
38
+ out[key] = '';
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+ async function readChunk(request, maxBytes) {
44
+ if (Buffer.isBuffer(request.body)) {
45
+ return request.body;
46
+ }
47
+ if (typeof request.body === 'string') {
48
+ return Buffer.from(request.body);
49
+ }
50
+ // Fastify only sets request.body when the Content-Type parser fires.
51
+ // Fall back to reading the raw stream, but enforce the same size cap to
52
+ // prevent bodyLimit from being bypassed by omitting the Content-Type header.
53
+ const parts = [];
54
+ let total = 0;
55
+ for await (const chunk of request.raw) {
56
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
57
+ total += buf.length;
58
+ if (total > maxBytes) {
59
+ throw new ApiError({ code: 413, message: 'Upload chunk exceeds maximum chunk size' });
60
+ }
61
+ parts.push(buf);
62
+ }
63
+ return Buffer.concat(parts);
64
+ }
65
+ function setTusHeaders(reply, supportsTermination = false) {
66
+ reply.header('Tus-Resumable', TUS_VERSION);
67
+ reply.header('Tus-Version', TUS_VERSION);
68
+ reply.header('Tus-Extension', supportsTermination ? 'creation,termination' : 'creation');
69
+ }
70
+ function toLocation(basePath, uploadId) {
71
+ const normalized = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
72
+ return `${normalized}/${uploadId}`;
73
+ }
74
+ export class TusUploadModule extends ApiModule {
75
+ constructor(options = {}) {
76
+ super({ namespace: '' });
77
+ const rawPath = options.basePath?.trim() || '/api/v1/upload';
78
+ this.basePath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
79
+ this.chunkMaxBytes = options.chunkMaxBytes ?? 64 * 1024 * 1024;
80
+ this.uploadMaxBytes = options.uploadMaxBytes ?? 10 * 1024 * 1024 * 1024; // 10 GiB
81
+ this.store = options.store ?? new MemoryTusUploadStore();
82
+ this.auth = options.auth;
83
+ this.onUploadComplete = options.onUploadComplete;
84
+ }
85
+ onMount() {
86
+ this.installTusRoutes();
87
+ }
88
+ /**
89
+ * Authenticate the request if an auth config was provided.
90
+ * Returns false and sends a 401/403 response if auth fails, so the caller
91
+ * can bail out early with `if (!await this.checkAuth(request, reply)) return`.
92
+ */
93
+ async checkAuth(request, reply) {
94
+ if (!this.auth) {
95
+ return null;
96
+ }
97
+ try {
98
+ const apiReq = await this.server.resolveRequest(request, reply, this.auth);
99
+ return apiReq.tokenData?.uid != null ? String(apiReq.tokenData.uid) : null;
100
+ }
101
+ catch (error) {
102
+ const code = error instanceof ApiError ? error.code : 401;
103
+ const message = error instanceof ApiError ? error.message : 'Unauthorized';
104
+ reply.code(code).send({ success: false, code, message, data: null, errors: {} });
105
+ return false;
106
+ }
107
+ }
108
+ authFailed(result) {
109
+ // When auth is configured and checkAuth catches an error, it returns
110
+ // `false as unknown as string` after sending a response. We distinguish
111
+ // that from a legitimate `null` (no auth configured / no uid in token)
112
+ // by checking the exact `false` sentinel.
113
+ return result === false;
114
+ }
115
+ verifyOwnership(upload, authUserId, reply) {
116
+ if (upload.userId && authUserId && upload.userId !== authUserId) {
117
+ reply
118
+ .code(403)
119
+ .send({ success: false, code: 403, message: 'Upload belongs to another user', data: null, errors: {} });
120
+ return false;
121
+ }
122
+ return true;
123
+ }
124
+ installTusRoutes() {
125
+ const app = this.server.fastify;
126
+ const hasTermination = typeof this.store.deleteUpload === 'function';
127
+ try {
128
+ app.addContentTypeParser('application/offset+octet-stream', { parseAs: 'buffer' }, (_req, body, done) => {
129
+ done(null, body);
130
+ });
131
+ }
132
+ catch (error) {
133
+ // Parser may already be present when mounting multiple upload modules.
134
+ const message = error instanceof Error ? error.message : '';
135
+ if (!message.includes('already registered') && !message.includes('already exists')) {
136
+ throw error;
137
+ }
138
+ }
139
+ app.options(this.basePath, async (_request, reply) => {
140
+ setTusHeaders(reply, hasTermination);
141
+ reply.code(204).send();
142
+ });
143
+ app.post(this.basePath, async (request, reply) => {
144
+ const authUserId = await this.checkAuth(request, reply);
145
+ if (this.authFailed(authUserId))
146
+ return;
147
+ setTusHeaders(reply, hasTermination);
148
+ const length = parseUploadLength(request.headers['upload-length']);
149
+ if (length <= 0) {
150
+ reply.code(400).send({
151
+ success: false,
152
+ code: 400,
153
+ message: 'Upload-Length must be greater than zero',
154
+ data: null,
155
+ errors: {}
156
+ });
157
+ return;
158
+ }
159
+ if (length > this.uploadMaxBytes) {
160
+ reply.code(413).send({
161
+ success: false,
162
+ code: 413,
163
+ message: `Upload-Length exceeds the maximum allowed size of ${this.uploadMaxBytes} bytes`,
164
+ data: null,
165
+ errors: {}
166
+ });
167
+ return;
168
+ }
169
+ const metadata = decodeMetadata(request.headers['upload-metadata']);
170
+ const created = await this.store.createUpload({ length, metadata, userId: authUserId ?? undefined });
171
+ reply.header('Location', toLocation(this.basePath, created.id));
172
+ reply.header('Upload-Offset', String(created.offset));
173
+ reply.code(201).send();
174
+ });
175
+ app.head(`${this.basePath}/:uploadId`, async (request, reply) => {
176
+ const authUserId = await this.checkAuth(request, reply);
177
+ if (this.authFailed(authUserId))
178
+ return;
179
+ setTusHeaders(reply, hasTermination);
180
+ const uploadId = String(request.params.uploadId ?? '');
181
+ const upload = await this.store.getUpload(uploadId);
182
+ if (!upload) {
183
+ reply.code(404).send();
184
+ return;
185
+ }
186
+ if (!this.verifyOwnership(upload, authUserId, reply))
187
+ return;
188
+ reply.header('Upload-Offset', String(upload.offset));
189
+ reply.header('Upload-Length', String(upload.length));
190
+ reply.code(200).send();
191
+ });
192
+ app.patch(`${this.basePath}/:uploadId`, { bodyLimit: this.chunkMaxBytes }, async (request, reply) => {
193
+ const authUserId = await this.checkAuth(request, reply);
194
+ if (this.authFailed(authUserId))
195
+ return;
196
+ setTusHeaders(reply, hasTermination);
197
+ const uploadId = String(request.params.uploadId ?? '');
198
+ const upload = await this.store.getUpload(uploadId);
199
+ if (!upload) {
200
+ reply.code(404).send();
201
+ return;
202
+ }
203
+ if (!this.verifyOwnership(upload, authUserId, reply))
204
+ return;
205
+ const offset = parseOffset(request.headers['upload-offset']);
206
+ if (offset !== upload.offset) {
207
+ reply.header('Upload-Offset', String(upload.offset));
208
+ reply.code(409).send();
209
+ return;
210
+ }
211
+ const chunk = await readChunk(request, this.chunkMaxBytes);
212
+ try {
213
+ const updated = await this.store.appendUpload({ uploadId, offset, chunk });
214
+ reply.header('Upload-Offset', String(updated.offset));
215
+ if (updated.completedAt && this.onUploadComplete) {
216
+ try {
217
+ await this.onUploadComplete(updated);
218
+ }
219
+ catch (completionError) {
220
+ console.error('[TusUploadModule] onUploadComplete callback failed', completionError);
221
+ }
222
+ }
223
+ reply.code(204).send();
224
+ }
225
+ catch (error) {
226
+ if (error instanceof TusUploadOffsetError) {
227
+ reply.header('Upload-Offset', String(error.currentOffset));
228
+ reply.code(409).send();
229
+ return;
230
+ }
231
+ if (error instanceof TusUploadExceedsLengthError) {
232
+ reply.code(413).send({
233
+ success: false,
234
+ code: 413,
235
+ message: 'Upload chunk exceeds declared upload length',
236
+ data: null,
237
+ errors: {}
238
+ });
239
+ return;
240
+ }
241
+ throw error;
242
+ }
243
+ });
244
+ app.delete(`${this.basePath}/:uploadId`, async (request, reply) => {
245
+ const authUserId = await this.checkAuth(request, reply);
246
+ if (this.authFailed(authUserId))
247
+ return;
248
+ setTusHeaders(reply, hasTermination);
249
+ const uploadId = String(request.params.uploadId ?? '');
250
+ if (!this.store.deleteUpload) {
251
+ reply.header('Allow', 'OPTIONS, POST, HEAD, PATCH');
252
+ reply.code(405).send();
253
+ return;
254
+ }
255
+ const upload = await this.store.getUpload(uploadId);
256
+ if (!upload) {
257
+ reply.code(404).send();
258
+ return;
259
+ }
260
+ if (!this.verifyOwnership(upload, authUserId, reply))
261
+ return;
262
+ const deleted = await this.store.deleteUpload(uploadId);
263
+ reply.code(deleted ? 204 : 404).send();
264
+ });
265
+ }
266
+ }
@@ -0,0 +1,28 @@
1
+ export type TusMetadata = Record<string, string>;
2
+ export interface TusUploadRecord {
3
+ id: string;
4
+ length: number;
5
+ offset: number;
6
+ metadata: TusMetadata;
7
+ userId?: string;
8
+ createdAt: Date;
9
+ updatedAt: Date;
10
+ completedAt?: Date;
11
+ }
12
+ export interface TusCreateUploadInput {
13
+ id?: string;
14
+ length: number;
15
+ metadata: TusMetadata;
16
+ userId?: string;
17
+ }
18
+ export interface TusAppendInput {
19
+ uploadId: string;
20
+ offset: number;
21
+ chunk: Buffer;
22
+ }
23
+ export interface TusUploadStore {
24
+ createUpload(input: TusCreateUploadInput): Promise<TusUploadRecord>;
25
+ getUpload(uploadId: string): Promise<TusUploadRecord | null>;
26
+ appendUpload(input: TusAppendInput): Promise<TusUploadRecord>;
27
+ deleteUpload?(uploadId: string): Promise<boolean>;
28
+ }
@@ -0,0 +1 @@
1
+ export {};