@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.
- package/README.md +3 -3
- package/demo/README.md +28 -0
- package/demo/dist/app.css +1 -0
- package/demo/dist/app.js +1184 -0
- package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
- package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
- package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
- package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
- package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
- package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
- package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
- package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
- package/demo/dist/favicon.ico +0 -0
- package/demo/dist/icons/timeline/audio.svg +7 -0
- package/demo/dist/icons/timeline/captions.svg +5 -0
- package/demo/dist/icons/timeline/composition.svg +12 -0
- package/demo/dist/icons/timeline/image.svg +18 -0
- package/demo/dist/icons/timeline/music.svg +10 -0
- package/demo/dist/icons/timeline/text.svg +3 -0
- package/demo/dist/index.html +15 -0
- package/dist/src/account-pages-legacy.js +9396 -0
- package/dist/src/account-pages.js +61 -0
- package/dist/src/app.js +14378 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/composition-runtime.js +613 -0
- package/dist/src/config.js +166 -0
- package/dist/src/context.js +447 -0
- package/dist/src/dev-app-legacy.js +739 -0
- package/dist/src/dev-app.js +6 -0
- package/dist/src/domain.js +2 -0
- package/dist/src/editor-chat-history.js +82 -0
- package/dist/src/editor-chat.js +449 -0
- package/dist/src/editor-dark-theme.js +1128 -0
- package/dist/src/frontend/debug.js +71 -0
- package/dist/src/frontend/flockposter-cache-store.js +124 -0
- package/dist/src/frontend/homepage-client.js +182 -0
- package/dist/src/frontend/homepage-shared.js +4 -0
- package/dist/src/frontend/homepage-store.js +28 -0
- package/dist/src/frontend/homepage-view.js +547 -0
- package/dist/src/frontend/page-runtime-client.js +132 -0
- package/dist/src/frontend/page-runtime-store.js +9 -0
- package/dist/src/frontend/sentry.js +42 -0
- package/dist/src/frontend/template-editor-chat.js +3960 -0
- package/dist/src/help-page.js +346 -0
- package/dist/src/homepage.js +1235 -0
- package/dist/src/hyperframes/composition.js +180 -0
- package/dist/src/index.js +16 -0
- package/dist/src/instrument.js +30 -0
- package/dist/src/lib/crypto.js +45 -0
- package/dist/src/lib/dev-log.js +54 -0
- package/dist/src/lib/display-name.js +11 -0
- package/dist/src/lib/ids.js +24 -0
- package/dist/src/lib/images.js +19 -0
- package/dist/src/lib/json.js +15 -0
- package/dist/src/lib/package-root.js +47 -0
- package/dist/src/lib/template-paths.js +28 -0
- package/dist/src/lib/time.js +7 -0
- package/dist/src/lib/url-clean.js +85 -0
- package/dist/src/page-runtime.js +2 -0
- package/dist/src/page-shell.js +1381 -0
- package/dist/src/primitive-context.js +357 -0
- package/dist/src/primitive-registry.js +2436 -0
- package/dist/src/primitive-sdk.js +4 -0
- package/dist/src/primitives/hyperframes-media.js +108 -0
- package/dist/src/react-page-shell.js +35 -0
- package/dist/src/ready-post-schedule-component.js +1540 -0
- package/dist/src/registry.js +296 -0
- package/dist/src/runtime.js +35 -0
- package/dist/src/services/api-call-history.js +249 -0
- package/dist/src/services/auth.js +152 -0
- package/dist/src/services/billing-pricing.js +39 -0
- package/dist/src/services/billing.js +228 -0
- package/dist/src/services/cast.js +127 -0
- package/dist/src/services/chat-threads.js +92 -0
- package/dist/src/services/composition-sanitize.js +124 -0
- package/dist/src/services/composition-watch.js +79 -0
- package/dist/src/services/fork-access.js +93 -0
- package/dist/src/services/fork-manifest.js +42 -0
- package/dist/src/services/ghostcut.js +179 -0
- package/dist/src/services/hyperframes.js +2307 -0
- package/dist/src/services/job-capacity.js +14 -0
- package/dist/src/services/job-logs.js +197 -0
- package/dist/src/services/jobs.js +136 -0
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/media-processing.js +766 -0
- package/dist/src/services/primitive-media-lambda.js +280 -0
- package/dist/src/services/providers.js +2926 -0
- package/dist/src/services/rate-limits.js +262 -0
- package/dist/src/services/serverless-auth.js +382 -0
- package/dist/src/services/serverless-jobs.js +1082 -0
- package/dist/src/services/serverless-provider-keys.js +409 -0
- package/dist/src/services/serverless-records.js +1385 -0
- package/dist/src/services/serverless-template-configs.js +75 -0
- package/dist/src/services/storage.js +383 -0
- package/dist/src/services/template-certification.js +413 -0
- package/dist/src/services/template-loader.js +99 -0
- package/dist/src/services/template-runtime-bundles.js +217 -0
- package/dist/src/services/template-sources.js +1017 -0
- package/dist/src/services/video-normalization.js +2 -0
- package/dist/src/services/webhooks.js +62 -0
- package/dist/src/template-editor-pages.js +2576 -0
- package/dist/src/template-editor-shell.js +2840 -0
- package/dist/src/template-sdk.js +4 -0
- package/dist/src/worker.js +17 -0
- package/package.json +6 -4
- package/public/assets/homepage-app.js +54 -0
- package/public/assets/homepage-client-app.js +80 -0
- package/public/assets/page-runtime-client-app.js +94 -0
- package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
- package/src/assets/SELLING_WITH_HOOKS.md +377 -0
- package/src/assets/SELLING_WITH_VSLS.md +606 -0
- package/src/assets/favicon.ico +0 -0
- 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
|