@mevdragon/vidfarm-devcli 0.5.3 → 0.6.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.
Files changed (113) hide show
  1. package/README.md +3 -3
  2. package/demo/README.md +28 -0
  3. package/demo/dist/app.css +1 -0
  4. package/demo/dist/app.js +1184 -0
  5. package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
  6. package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
  7. package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
  8. package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
  9. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
  10. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
  11. package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
  12. package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
  13. package/demo/dist/favicon.ico +0 -0
  14. package/demo/dist/icons/timeline/audio.svg +7 -0
  15. package/demo/dist/icons/timeline/captions.svg +5 -0
  16. package/demo/dist/icons/timeline/composition.svg +12 -0
  17. package/demo/dist/icons/timeline/image.svg +18 -0
  18. package/demo/dist/icons/timeline/music.svg +10 -0
  19. package/demo/dist/icons/timeline/text.svg +3 -0
  20. package/demo/dist/index.html +15 -0
  21. package/dist/src/account-pages-legacy.js +9396 -0
  22. package/dist/src/account-pages.js +61 -0
  23. package/dist/src/app.js +14378 -0
  24. package/dist/src/cli.js +1 -1
  25. package/dist/src/composition-runtime.js +613 -0
  26. package/dist/src/config.js +166 -0
  27. package/dist/src/context.js +447 -0
  28. package/dist/src/dev-app-legacy.js +739 -0
  29. package/dist/src/dev-app.js +6 -0
  30. package/dist/src/domain.js +2 -0
  31. package/dist/src/editor-chat-history.js +82 -0
  32. package/dist/src/editor-chat.js +449 -0
  33. package/dist/src/editor-dark-theme.js +1128 -0
  34. package/dist/src/frontend/debug.js +71 -0
  35. package/dist/src/frontend/flockposter-cache-store.js +124 -0
  36. package/dist/src/frontend/homepage-client.js +182 -0
  37. package/dist/src/frontend/homepage-shared.js +4 -0
  38. package/dist/src/frontend/homepage-store.js +28 -0
  39. package/dist/src/frontend/homepage-view.js +547 -0
  40. package/dist/src/frontend/page-runtime-client.js +132 -0
  41. package/dist/src/frontend/page-runtime-store.js +9 -0
  42. package/dist/src/frontend/sentry.js +42 -0
  43. package/dist/src/frontend/template-editor-chat.js +3960 -0
  44. package/dist/src/help-page.js +346 -0
  45. package/dist/src/homepage.js +1235 -0
  46. package/dist/src/hyperframes/composition.js +180 -0
  47. package/dist/src/index.js +16 -0
  48. package/dist/src/instrument.js +30 -0
  49. package/dist/src/lib/crypto.js +45 -0
  50. package/dist/src/lib/dev-log.js +54 -0
  51. package/dist/src/lib/display-name.js +11 -0
  52. package/dist/src/lib/ids.js +24 -0
  53. package/dist/src/lib/images.js +19 -0
  54. package/dist/src/lib/json.js +15 -0
  55. package/dist/src/lib/package-root.js +47 -0
  56. package/dist/src/lib/template-paths.js +28 -0
  57. package/dist/src/lib/time.js +7 -0
  58. package/dist/src/lib/url-clean.js +85 -0
  59. package/dist/src/page-runtime.js +2 -0
  60. package/dist/src/page-shell.js +1381 -0
  61. package/dist/src/primitive-context.js +357 -0
  62. package/dist/src/primitive-registry.js +2436 -0
  63. package/dist/src/primitive-sdk.js +4 -0
  64. package/dist/src/primitives/hyperframes-media.js +108 -0
  65. package/dist/src/react-page-shell.js +35 -0
  66. package/dist/src/ready-post-schedule-component.js +1540 -0
  67. package/dist/src/registry.js +296 -0
  68. package/dist/src/runtime.js +35 -0
  69. package/dist/src/services/api-call-history.js +249 -0
  70. package/dist/src/services/auth.js +152 -0
  71. package/dist/src/services/billing-pricing.js +39 -0
  72. package/dist/src/services/billing.js +228 -0
  73. package/dist/src/services/cast.js +127 -0
  74. package/dist/src/services/chat-threads.js +92 -0
  75. package/dist/src/services/composition-sanitize.js +124 -0
  76. package/dist/src/services/composition-watch.js +79 -0
  77. package/dist/src/services/fork-access.js +93 -0
  78. package/dist/src/services/fork-manifest.js +42 -0
  79. package/dist/src/services/ghostcut.js +179 -0
  80. package/dist/src/services/hyperframes.js +2307 -0
  81. package/dist/src/services/job-capacity.js +14 -0
  82. package/dist/src/services/job-logs.js +197 -0
  83. package/dist/src/services/jobs.js +136 -0
  84. package/dist/src/services/local-dynamo.js +0 -0
  85. package/dist/src/services/media-processing.js +766 -0
  86. package/dist/src/services/primitive-media-lambda.js +280 -0
  87. package/dist/src/services/providers.js +2926 -0
  88. package/dist/src/services/rate-limits.js +262 -0
  89. package/dist/src/services/serverless-auth.js +382 -0
  90. package/dist/src/services/serverless-jobs.js +1082 -0
  91. package/dist/src/services/serverless-provider-keys.js +409 -0
  92. package/dist/src/services/serverless-records.js +1385 -0
  93. package/dist/src/services/serverless-template-configs.js +75 -0
  94. package/dist/src/services/storage.js +383 -0
  95. package/dist/src/services/template-certification.js +413 -0
  96. package/dist/src/services/template-loader.js +99 -0
  97. package/dist/src/services/template-runtime-bundles.js +217 -0
  98. package/dist/src/services/template-sources.js +1017 -0
  99. package/dist/src/services/video-normalization.js +2 -0
  100. package/dist/src/services/webhooks.js +62 -0
  101. package/dist/src/template-editor-pages.js +2576 -0
  102. package/dist/src/template-editor-shell.js +2840 -0
  103. package/dist/src/template-sdk.js +4 -0
  104. package/dist/src/worker.js +17 -0
  105. package/package.json +6 -4
  106. package/public/assets/homepage-app.js +54 -0
  107. package/public/assets/homepage-client-app.js +80 -0
  108. package/public/assets/page-runtime-client-app.js +94 -0
  109. package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
  110. package/src/assets/SELLING_WITH_HOOKS.md +377 -0
  111. package/src/assets/SELLING_WITH_VSLS.md +606 -0
  112. package/src/assets/favicon.ico +0 -0
  113. package/src/assets/logo-vidfarm.png +0 -0
@@ -0,0 +1,262 @@
1
+ import { DynamoDBClient, GetItemCommand, TransactWriteItemsCommand } from "@aws-sdk/client-dynamodb";
2
+ import { config } from "../config.js";
3
+ const dynamodb = new DynamoDBClient({});
4
+ const OPERATIONS_TABLE_NAME = config.VIDFARM_RATE_LIMITS_TABLE_NAME?.trim() || "";
5
+ const REST_LIMIT_SUMMARY = "General REST API limits: max 10 requests per second, 300 requests per minute, and 5000 requests per hour.";
6
+ export class RateLimitExceededError extends Error {
7
+ type;
8
+ status;
9
+ constructor(message, type, status) {
10
+ super(message);
11
+ this.type = type;
12
+ this.status = status;
13
+ }
14
+ }
15
+ const OPERATION_LIMITS = {
16
+ client: {
17
+ month: 750,
18
+ week: 400,
19
+ five_hour: 200
20
+ },
21
+ standalone: {
22
+ month: 2500,
23
+ week: 1250,
24
+ five_hour: 500
25
+ },
26
+ agency: {
27
+ month: 10_000,
28
+ week: 5_000,
29
+ five_hour: 2_000
30
+ }
31
+ };
32
+ const REST_LIMITS = {
33
+ second: 10,
34
+ minute: 300,
35
+ hour: 5_000
36
+ };
37
+ // Composition writes (working-copy PUT/PATCH + version snapshots) are far more
38
+ // expensive than a generic read — each is an S3 write, and snapshots also grow
39
+ // the fork's namespace. They get a dedicated, much tighter bucket on top of the
40
+ // general REST limiter so a single (possibly compromised) key can't hammer S3
41
+ // or spam versions.
42
+ const PUBLISH_LIMITS = {
43
+ minute: 20,
44
+ hour: 300
45
+ };
46
+ const WINDOW_DEFINITIONS = {
47
+ month: { durationMs: 30 * 24 * 60 * 60 * 1000, label: "30-day window" },
48
+ week: { durationMs: 7 * 24 * 60 * 60 * 1000, label: "7-day window" },
49
+ five_hour: { durationMs: 5 * 60 * 60 * 1000, label: "5-hour window" },
50
+ second: { durationMs: 1000, label: "1-second window" },
51
+ minute: { durationMs: 60 * 1000, label: "1-minute window" },
52
+ hour: { durationMs: 60 * 60 * 1000, label: "1-hour window" }
53
+ };
54
+ export class RateLimitService {
55
+ isConfigured() {
56
+ return Boolean(OPERATIONS_TABLE_NAME);
57
+ }
58
+ async consumeTemplateOperation(customer) {
59
+ if (!this.isConfigured()) {
60
+ return;
61
+ }
62
+ await this.consumeWindows({
63
+ scopePk: `customer#${customer.id}`,
64
+ scopeLabel: `customer:${customer.id}`,
65
+ anchorMs: new Date(customer.createdAt).getTime(),
66
+ limits: OPERATION_LIMITS[customer.planTier],
67
+ type: "operation_rate_limit_exceeded"
68
+ });
69
+ }
70
+ async consumeGeneralRest(scopeId) {
71
+ if (!this.isConfigured()) {
72
+ return;
73
+ }
74
+ const statuses = await this.consumeWindows({
75
+ scopePk: `rest#${scopeId}`,
76
+ scopeLabel: scopeId,
77
+ anchorMs: 0,
78
+ limits: REST_LIMITS,
79
+ type: "rest_rate_limit_exceeded"
80
+ });
81
+ const violations = statuses.filter((status) => status.used > status.limit);
82
+ if (violations.length > 0) {
83
+ throw new RateLimitExceededError(violations[0].message, "rest_rate_limit_exceeded", violations);
84
+ }
85
+ }
86
+ async consumePublish(scopeId) {
87
+ if (!this.isConfigured()) {
88
+ return;
89
+ }
90
+ const statuses = await this.consumeWindows({
91
+ scopePk: `publish#${scopeId}`,
92
+ scopeLabel: scopeId,
93
+ anchorMs: 0,
94
+ limits: PUBLISH_LIMITS,
95
+ type: "publish_rate_limit_exceeded"
96
+ });
97
+ const violations = statuses.filter((status) => status.used > status.limit);
98
+ if (violations.length > 0) {
99
+ throw new RateLimitExceededError(violations[0].message, "publish_rate_limit_exceeded", violations);
100
+ }
101
+ }
102
+ async getTemplateOperationStatus(customer) {
103
+ if (!this.isConfigured()) {
104
+ return {
105
+ monthly: this.buildDisabledStatus("month", "30-day window"),
106
+ weekly: this.buildDisabledStatus("week", "7-day window"),
107
+ five_hour: this.buildDisabledStatus("five_hour", "5-hour window")
108
+ };
109
+ }
110
+ const statuses = await this.readWindows({
111
+ scopePk: `customer#${customer.id}`,
112
+ scopeLabel: `customer:${customer.id}`,
113
+ anchorMs: new Date(customer.createdAt).getTime(),
114
+ limits: OPERATION_LIMITS[customer.planTier]
115
+ });
116
+ const mapped = this.buildOperationStatuses(statuses);
117
+ return {
118
+ monthly: mapped.find((status) => status.key === "month"),
119
+ weekly: mapped.find((status) => status.key === "week"),
120
+ five_hour: mapped.find((status) => status.key === "five_hour")
121
+ };
122
+ }
123
+ getRestSummary() {
124
+ return {
125
+ message: REST_LIMIT_SUMMARY,
126
+ limits: {
127
+ per_second: REST_LIMITS.second,
128
+ per_minute: REST_LIMITS.minute,
129
+ per_hour: REST_LIMITS.hour
130
+ }
131
+ };
132
+ }
133
+ async consumeWindows(input) {
134
+ const windowSpecs = this.buildWindowSpecs(input.anchorMs, input.limits);
135
+ let transactionCancelled = false;
136
+ try {
137
+ await dynamodb.send(new TransactWriteItemsCommand({
138
+ TransactItems: windowSpecs.map((spec) => ({
139
+ Update: {
140
+ TableName: OPERATIONS_TABLE_NAME,
141
+ Key: {
142
+ pk: { S: input.scopePk },
143
+ sk: { S: spec.sk }
144
+ },
145
+ UpdateExpression: "SET usage_count = if_not_exists(usage_count, :zero) + :one, #scope = if_not_exists(#scope, :scope), window_key = if_not_exists(window_key, :windowKey), window_started_at = if_not_exists(window_started_at, :windowStartedAt), window_ends_at = if_not_exists(window_ends_at, :windowEndsAt), limit_value = if_not_exists(limit_value, :limitValue), created_at = if_not_exists(created_at, :createdAt), updated_at = :updatedAt, expires_at_epoch_seconds = if_not_exists(expires_at_epoch_seconds, :ttl)",
146
+ ConditionExpression: "attribute_not_exists(usage_count) OR usage_count < :limitValue",
147
+ ExpressionAttributeNames: {
148
+ "#scope": "scope",
149
+ },
150
+ ExpressionAttributeValues: {
151
+ ":zero": { N: "0" },
152
+ ":one": { N: "1" },
153
+ ":scope": { S: input.scopeLabel },
154
+ ":windowKey": { S: spec.key },
155
+ ":windowStartedAt": { S: spec.windowStartedAt },
156
+ ":windowEndsAt": { S: spec.nextAvailableAt },
157
+ ":limitValue": { N: String(spec.limit) },
158
+ ":createdAt": { S: new Date().toISOString() },
159
+ ":updatedAt": { S: new Date().toISOString() },
160
+ ":ttl": { N: String(Math.floor((new Date(spec.nextAvailableAt).getTime() + (2 * 24 * 60 * 60 * 1000)) / 1000)) }
161
+ }
162
+ }
163
+ }))
164
+ }));
165
+ }
166
+ catch (error) {
167
+ if (!isTransactionCanceledError(error)) {
168
+ throw error;
169
+ }
170
+ transactionCancelled = true;
171
+ }
172
+ const statuses = await this.readWindows({
173
+ scopePk: input.scopePk,
174
+ scopeLabel: input.scopeLabel,
175
+ anchorMs: input.anchorMs,
176
+ limits: input.limits
177
+ });
178
+ const violations = statuses.filter((status) => transactionCancelled ? status.used >= status.limit : status.used > status.limit);
179
+ if (violations.length > 0) {
180
+ throw new RateLimitExceededError(violations[0].message, input.type, violations);
181
+ }
182
+ return statuses;
183
+ }
184
+ async readWindows(input) {
185
+ const windowSpecs = this.buildWindowSpecs(input.anchorMs, input.limits);
186
+ const items = await Promise.all(windowSpecs.map(async (spec) => {
187
+ const response = await dynamodb.send(new GetItemCommand({
188
+ TableName: OPERATIONS_TABLE_NAME,
189
+ Key: {
190
+ pk: { S: input.scopePk },
191
+ sk: { S: spec.sk }
192
+ },
193
+ ConsistentRead: true
194
+ }));
195
+ return this.toStatus(spec, response.Item);
196
+ }));
197
+ return items;
198
+ }
199
+ buildOperationStatuses(statuses) {
200
+ return statuses.map((status) => ({
201
+ ...status,
202
+ key: status.key === "month" ? "month" : status.key === "week" ? "week" : "five_hour"
203
+ }));
204
+ }
205
+ buildWindowSpecs(anchorMs, limits) {
206
+ return Object.keys(limits).map((typedKey) => {
207
+ const limit = limits[typedKey];
208
+ const definition = WINDOW_DEFINITIONS[typedKey];
209
+ const nowMs = Date.now();
210
+ const effectiveAnchorMs = Number.isFinite(anchorMs) && anchorMs > 0 ? anchorMs : 0;
211
+ const elapsedMs = Math.max(0, nowMs - effectiveAnchorMs);
212
+ const windowIndex = Math.floor(elapsedMs / definition.durationMs);
213
+ const windowStartedAtMs = effectiveAnchorMs + (windowIndex * definition.durationMs);
214
+ const nextAvailableAtMs = windowStartedAtMs + definition.durationMs;
215
+ const windowStartedAt = new Date(windowStartedAtMs).toISOString();
216
+ const nextAvailableAt = new Date(nextAvailableAtMs).toISOString();
217
+ return {
218
+ key: typedKey,
219
+ label: definition.label,
220
+ limit,
221
+ sk: `${typedKey}#${windowStartedAt}`,
222
+ windowStartedAt,
223
+ nextAvailableAt
224
+ };
225
+ });
226
+ }
227
+ toStatus(spec, item) {
228
+ const used = Number(item?.usage_count?.N ?? "0");
229
+ const remaining = Math.max(0, spec.limit - used);
230
+ const consumedPercent = spec.limit > 0 ? Math.min(100, Number(((used / spec.limit) * 100).toFixed(2))) : 100;
231
+ return {
232
+ key: spec.key,
233
+ label: spec.label,
234
+ limit: spec.limit,
235
+ used,
236
+ remaining,
237
+ consumedPercent,
238
+ windowStartedAt: item?.window_started_at?.S ?? spec.windowStartedAt,
239
+ nextAvailableAt: item?.window_ends_at?.S ?? spec.nextAvailableAt,
240
+ message: used >= spec.limit
241
+ ? `Rate limit reached for the ${spec.label}. Usage resets at ${item?.window_ends_at?.S ?? spec.nextAvailableAt}.`
242
+ : `${used} of ${spec.limit} requests used in the ${spec.label}. Resets at ${item?.window_ends_at?.S ?? spec.nextAvailableAt}.`
243
+ };
244
+ }
245
+ buildDisabledStatus(key, label) {
246
+ return {
247
+ key,
248
+ label,
249
+ limit: 0,
250
+ used: 0,
251
+ remaining: 0,
252
+ consumedPercent: 0,
253
+ windowStartedAt: new Date(0).toISOString(),
254
+ nextAvailableAt: new Date(0).toISOString(),
255
+ message: "Rate limiting is not configured for this environment."
256
+ };
257
+ }
258
+ }
259
+ function isTransactionCanceledError(error) {
260
+ return error instanceof Error && error.name === "TransactionCanceledException";
261
+ }
262
+ //# sourceMappingURL=rate-limits.js.map
@@ -0,0 +1,382 @@
1
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ import { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
3
+ import { config } from "../config.js";
4
+ import { hashSecret, safeEqualHash } from "../lib/crypto.js";
5
+ import { devLog } from "../lib/dev-log.js";
6
+ import { displayNameFromEmail } from "../lib/display-name.js";
7
+ import { createId } from "../lib/ids.js";
8
+ import { nowIso } from "../lib/time.js";
9
+ import { createLocalDynamoClient } from "./local-dynamo.js";
10
+ // See local-dynamo.ts: `vidfarm serve` swaps the DynamoDB client for a
11
+ // disk-backed shim; deployed environments keep the real client.
12
+ const dynamodb = config.RECORDS_DRIVER === "local"
13
+ ? createLocalDynamoClient()
14
+ : DynamoDBDocumentClient.from(new DynamoDBClient({}), {
15
+ marshallOptions: {
16
+ removeUndefinedValues: true
17
+ }
18
+ });
19
+ export class ServerlessAuthService {
20
+ async insertOtpChallenge(record) {
21
+ const timestamp = nowIso();
22
+ const email = record.email.trim().toLowerCase();
23
+ await dynamodb.send(new PutCommand({
24
+ TableName: requireMainTableName(),
25
+ Item: {
26
+ pk: `EMAIL#${email}`,
27
+ sk: `OTP#${timestamp}#${record.id}`,
28
+ entity_type: "OtpChallenge",
29
+ id: record.id,
30
+ email,
31
+ code_hash: record.codeHash,
32
+ expires_at: record.expiresAt,
33
+ consumed_at: null,
34
+ created_at: timestamp
35
+ }
36
+ }));
37
+ devLog("auth.otp.inserted", {
38
+ otp_id: record.id,
39
+ email,
40
+ expires_at: record.expiresAt
41
+ });
42
+ }
43
+ async findLatestOtpChallenge(email) {
44
+ const response = await dynamodb.send(new QueryCommand({
45
+ TableName: requireMainTableName(),
46
+ KeyConditionExpression: "pk = :pk and begins_with(sk, :prefix)",
47
+ ExpressionAttributeValues: {
48
+ ":pk": `EMAIL#${email.trim().toLowerCase()}`,
49
+ ":prefix": "OTP#"
50
+ },
51
+ ScanIndexForward: false,
52
+ Limit: 1
53
+ }));
54
+ devLog("auth.otp.lookup", {
55
+ email: email.trim().toLowerCase(),
56
+ found: Boolean(response.Items?.[0])
57
+ });
58
+ return response.Items?.[0] ?? null;
59
+ }
60
+ async consumeOtpChallenge(email, id) {
61
+ const challenge = await this.findLatestOtpChallenge(email);
62
+ if (!challenge || String(challenge.id) !== id) {
63
+ return;
64
+ }
65
+ await dynamodb.send(new UpdateCommand({
66
+ TableName: requireMainTableName(),
67
+ Key: {
68
+ pk: String(challenge.pk),
69
+ sk: String(challenge.sk)
70
+ },
71
+ UpdateExpression: "set consumed_at = :now",
72
+ ExpressionAttributeValues: {
73
+ ":now": nowIso()
74
+ }
75
+ }));
76
+ devLog("auth.otp.consumed", {
77
+ otp_id: id,
78
+ email: email.trim().toLowerCase()
79
+ });
80
+ }
81
+ async upsertCustomer(input) {
82
+ const current = await this.getCustomer(requireMainTableName(), input.id);
83
+ const timestamp = nowIso();
84
+ const email = input.email.trim().toLowerCase();
85
+ const customer = {
86
+ id: input.id,
87
+ email,
88
+ name: input.name === undefined ? current?.name ?? displayNameFromEmail(email) : input.name,
89
+ defaultWebhookUrl: input.defaultWebhookUrl === undefined ? current?.defaultWebhookUrl ?? null : input.defaultWebhookUrl,
90
+ isDeveloper: input.isDeveloper === undefined ? current?.isDeveloper ?? false : input.isDeveloper,
91
+ isAgency: input.isAgency === undefined ? current?.isAgency ?? false : input.isAgency,
92
+ isPaidPlan: input.isPaidPlan === undefined ? current?.isPaidPlan ?? false : input.isPaidPlan,
93
+ planTier: input.planTier === undefined ? current?.planTier ?? "client" : input.planTier,
94
+ about: input.about === undefined ? current?.about ?? null : input.about,
95
+ groupchatUrl: input.groupchatUrl === undefined ? current?.groupchatUrl ?? null : input.groupchatUrl,
96
+ flockposterApiKey: input.flockposterApiKey === undefined ? current?.flockposterApiKey ?? null : input.flockposterApiKey,
97
+ createdAt: current?.createdAt ?? timestamp,
98
+ updatedAt: timestamp
99
+ };
100
+ await this.putCustomerItem(requireMainTableName(), customer, {
101
+ passwordHash: input.passwordHash === undefined ? undefined : input.passwordHash,
102
+ encryptedPassword: input.encryptedPassword === undefined ? undefined : input.encryptedPassword
103
+ });
104
+ devLog("auth.customer.upserted", {
105
+ customer_id: customer.id,
106
+ email: customer.email,
107
+ is_paid_plan: customer.isPaidPlan,
108
+ plan_tier: customer.planTier
109
+ });
110
+ return customer;
111
+ }
112
+ async getCustomerById(customerId) {
113
+ return this.getCustomer(requireMainTableName(), customerId);
114
+ }
115
+ async getCustomerByEmailPublic(email) {
116
+ return this.getCustomerByEmail(requireMainTableName(), email);
117
+ }
118
+ async getCustomerAuthByEmail(email) {
119
+ const response = await dynamodb.send(new QueryCommand({
120
+ TableName: requireMainTableName(),
121
+ IndexName: "gsi1",
122
+ KeyConditionExpression: "gsi1pk = :pk",
123
+ ExpressionAttributeValues: {
124
+ ":pk": `CUSTOMER_EMAIL#${email.trim().toLowerCase()}`
125
+ },
126
+ Limit: 1
127
+ }));
128
+ return response.Items?.[0] ?? null;
129
+ }
130
+ async updateCustomerPassword(customerId, passwordHash, encryptedPassword) {
131
+ await dynamodb.send(new UpdateCommand({
132
+ TableName: requireMainTableName(),
133
+ Key: {
134
+ pk: `CUSTOMER#${customerId}`,
135
+ sk: "META"
136
+ },
137
+ UpdateExpression: "set password_hash = :hash, encrypted_password = :encrypted, updated_at = :updated",
138
+ ExpressionAttributeValues: {
139
+ ":hash": passwordHash,
140
+ ":encrypted": encryptedPassword ?? null,
141
+ ":updated": nowIso()
142
+ }
143
+ }));
144
+ }
145
+ async insertApiKey(record) {
146
+ await this.putApiKey(requireMainTableName(), {
147
+ id: record.id,
148
+ keyHash: record.keyHash,
149
+ customerId: record.customerId,
150
+ rawValue: record.rawValue ?? null,
151
+ label: record.label
152
+ });
153
+ devLog("auth.api_key.inserted", {
154
+ api_key_id: record.id,
155
+ customer_id: record.customerId,
156
+ label: record.label
157
+ });
158
+ }
159
+ async findApiKeyByHash(keyHash) {
160
+ return this.getApiKeyItem(requireMainTableName(), keyHash);
161
+ }
162
+ async authenticate(apiKey) {
163
+ if (!apiKey) {
164
+ throw new Error("Missing vidfarm-api-key header.");
165
+ }
166
+ const tableName = requireMainTableName();
167
+ const keyHash = hashSecret(apiKey + config.API_KEY_SALT);
168
+ const apiKeyItem = await this.getApiKeyItem(tableName, keyHash);
169
+ if (apiKeyItem) {
170
+ const customer = await this.getCustomer(tableName, String(apiKeyItem.customer_id));
171
+ if (!customer) {
172
+ throw new Error("Invalid API credentials.");
173
+ }
174
+ if (!customer.isPaidPlan) {
175
+ throw new Error("Paid plan required.");
176
+ }
177
+ await this.touchApiKey(tableName, keyHash);
178
+ devLog("auth.api_key.authenticated", {
179
+ customer_id: customer.id,
180
+ email: customer.email
181
+ });
182
+ return customer;
183
+ }
184
+ const bootstrapped = await this.tryBootstrapFromEnv(tableName, apiKey, keyHash);
185
+ if (bootstrapped) {
186
+ devLog("auth.bootstrap.authenticated", {
187
+ customer_id: bootstrapped.id,
188
+ email: bootstrapped.email
189
+ });
190
+ return bootstrapped;
191
+ }
192
+ devLog("auth.api_key.rejected", {
193
+ reason: "not_found"
194
+ }, "warn");
195
+ throw new Error("Invalid API credentials.");
196
+ }
197
+ async getApiKeyItem(tableName, keyHash) {
198
+ const response = await dynamodb.send(new GetCommand({
199
+ TableName: tableName,
200
+ Key: {
201
+ pk: `APIKEY#${keyHash}`,
202
+ sk: "META"
203
+ }
204
+ }));
205
+ if (!response.Item || response.Item.status !== "active") {
206
+ return null;
207
+ }
208
+ return response.Item;
209
+ }
210
+ async getCustomer(tableName, customerId) {
211
+ const response = await dynamodb.send(new GetCommand({
212
+ TableName: tableName,
213
+ Key: {
214
+ pk: `CUSTOMER#${customerId}`,
215
+ sk: "META"
216
+ }
217
+ }));
218
+ return response.Item ? itemToCustomer(response.Item) : null;
219
+ }
220
+ async getCustomerByEmail(tableName, email) {
221
+ const response = await dynamodb.send(new QueryCommand({
222
+ TableName: tableName,
223
+ IndexName: "gsi1",
224
+ KeyConditionExpression: "gsi1pk = :pk",
225
+ ExpressionAttributeValues: {
226
+ ":pk": `CUSTOMER_EMAIL#${email.trim().toLowerCase()}`
227
+ },
228
+ Limit: 1
229
+ }));
230
+ const item = response.Items?.[0];
231
+ return item ? itemToCustomer(item) : null;
232
+ }
233
+ async tryBootstrapFromEnv(tableName, apiKey, keyHash) {
234
+ const bootstrapApiKey = process.env.VIDFARM_STAGING_BOOTSTRAP_API_KEY?.trim()
235
+ || process.env.VIDFARM_API_KEY?.trim()
236
+ || "";
237
+ if (!bootstrapApiKey || !safeEqualHash(apiKey, hashSecret(bootstrapApiKey))) {
238
+ return null;
239
+ }
240
+ const email = process.env.VIDFARM_STAGING_BOOTSTRAP_EMAIL?.trim()
241
+ || config.adminEmails[0]
242
+ || "staging@vidfarm.local";
243
+ const existing = await this.getCustomerByEmail(tableName, email);
244
+ const customer = existing ?? buildBootstrapCustomer(email);
245
+ await this.putCustomerItem(tableName, customer, {});
246
+ await this.putApiKey(tableName, {
247
+ keyHash,
248
+ customerId: customer.id,
249
+ rawValue: apiKey,
250
+ label: `Staging bootstrap ${nowIso()}`
251
+ });
252
+ return customer;
253
+ }
254
+ async putCustomerItem(tableName, customer, extra) {
255
+ const existing = await dynamodb.send(new GetCommand({
256
+ TableName: tableName,
257
+ Key: {
258
+ pk: `CUSTOMER#${customer.id}`,
259
+ sk: "META"
260
+ }
261
+ }));
262
+ await dynamodb.send(new PutCommand({
263
+ TableName: tableName,
264
+ Item: {
265
+ ...customerToItem(customer),
266
+ password_hash: extra.passwordHash === undefined ? existing.Item?.password_hash ?? null : extra.passwordHash,
267
+ encrypted_password: extra.encryptedPassword === undefined ? existing.Item?.encrypted_password ?? null : extra.encryptedPassword
268
+ }
269
+ }));
270
+ }
271
+ async putApiKey(tableName, input) {
272
+ const timestamp = nowIso();
273
+ await dynamodb.send(new PutCommand({
274
+ TableName: tableName,
275
+ Item: {
276
+ pk: `APIKEY#${input.keyHash}`,
277
+ sk: "META",
278
+ entity_type: "ApiKey",
279
+ gsi1pk: `CUSTOMER#${input.customerId}#APIKEYS`,
280
+ gsi1sk: `APIKEY#${timestamp}#${input.id ?? input.keyHash}`,
281
+ api_key_id: input.id ?? input.keyHash,
282
+ key_hash: input.keyHash,
283
+ customer_id: input.customerId,
284
+ raw_value: input.rawValue ?? null,
285
+ label: input.label ?? null,
286
+ status: "active",
287
+ created_at: timestamp,
288
+ updated_at: timestamp
289
+ }
290
+ }));
291
+ }
292
+ async touchApiKey(tableName, keyHash) {
293
+ const timestamp = nowIso();
294
+ await dynamodb.send(new UpdateCommand({
295
+ TableName: tableName,
296
+ Key: {
297
+ pk: `APIKEY#${keyHash}`,
298
+ sk: "META"
299
+ },
300
+ UpdateExpression: "set last_used_at = :now, updated_at = :now",
301
+ ExpressionAttributeValues: {
302
+ ":now": timestamp
303
+ }
304
+ }));
305
+ }
306
+ }
307
+ function requireMainTableName() {
308
+ if (config.RECORDS_DRIVER === "local") {
309
+ return config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME || "vidfarm-local-main";
310
+ }
311
+ if (!config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME) {
312
+ throw new Error("Serverless auth requires VIDFARM_SERVERLESS_MAIN_TABLE_NAME.");
313
+ }
314
+ return config.VIDFARM_SERVERLESS_MAIN_TABLE_NAME;
315
+ }
316
+ function buildBootstrapCustomer(email) {
317
+ const timestamp = nowIso();
318
+ const id = process.env.VIDFARM_STAGING_BOOTSTRAP_USER_ID?.trim()
319
+ || process.env.VIDFARM_USER_ID?.trim()
320
+ || createId("cus");
321
+ return {
322
+ id,
323
+ email: email.trim().toLowerCase(),
324
+ name: displayNameFromEmail(email),
325
+ defaultWebhookUrl: null,
326
+ isDeveloper: true,
327
+ isAgency: true,
328
+ isPaidPlan: true,
329
+ planTier: "agency",
330
+ about: null,
331
+ groupchatUrl: null,
332
+ flockposterApiKey: null,
333
+ createdAt: timestamp,
334
+ updatedAt: timestamp
335
+ };
336
+ }
337
+ function customerToItem(customer) {
338
+ return {
339
+ pk: `CUSTOMER#${customer.id}`,
340
+ sk: "META",
341
+ entity_type: "Customer",
342
+ gsi1pk: `CUSTOMER_EMAIL#${customer.email.trim().toLowerCase()}`,
343
+ gsi1sk: `CUSTOMER#${customer.id}`,
344
+ customer_id: customer.id,
345
+ email: customer.email,
346
+ name: customer.name,
347
+ default_webhook_url: customer.defaultWebhookUrl,
348
+ is_developer: customer.isDeveloper,
349
+ is_agency: customer.isAgency,
350
+ is_paid_plan: customer.isPaidPlan,
351
+ plan_tier: customer.planTier,
352
+ about: customer.about,
353
+ groupchat_url: customer.groupchatUrl,
354
+ flockposter_api_key: customer.flockposterApiKey,
355
+ created_at: customer.createdAt,
356
+ updated_at: customer.updatedAt
357
+ };
358
+ }
359
+ function itemToCustomer(item) {
360
+ return {
361
+ id: String(item.customer_id),
362
+ email: String(item.email),
363
+ name: typeof item.name === "string" ? item.name : null,
364
+ defaultWebhookUrl: typeof item.default_webhook_url === "string" ? item.default_webhook_url : null,
365
+ isDeveloper: Boolean(item.is_developer),
366
+ isAgency: Boolean(item.is_agency),
367
+ isPaidPlan: Boolean(item.is_paid_plan),
368
+ planTier: normalizePlanTier(item.plan_tier),
369
+ about: typeof item.about === "string" ? item.about : null,
370
+ groupchatUrl: typeof item.groupchat_url === "string" ? item.groupchat_url : null,
371
+ flockposterApiKey: typeof item.flockposter_api_key === "string" ? item.flockposter_api_key : null,
372
+ createdAt: String(item.created_at),
373
+ updatedAt: String(item.updated_at)
374
+ };
375
+ }
376
+ function normalizePlanTier(value) {
377
+ if (value === "client" || value === "standalone" || value === "agency") {
378
+ return value;
379
+ }
380
+ return "client";
381
+ }
382
+ //# sourceMappingURL=serverless-auth.js.map