@selvajs/server 1.1.0 → 1.3.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/dist/access/guards.d.ts +213 -0
- package/dist/access/guards.d.ts.map +1 -0
- package/dist/access/guards.js +417 -0
- package/dist/access/guards.js.map +1 -0
- package/dist/access/index.d.ts +2 -0
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +6 -0
- package/dist/access/index.js.map +1 -1
- package/dist/access/permissions-scope.d.ts +15 -0
- package/dist/access/permissions-scope.d.ts.map +1 -0
- package/dist/access/permissions-scope.js +23 -0
- package/dist/access/permissions-scope.js.map +1 -0
- package/dist/api/bodies.d.ts +104 -0
- package/dist/api/bodies.d.ts.map +1 -0
- package/dist/api/bodies.js +87 -0
- package/dist/api/bodies.js.map +1 -0
- package/dist/api/deps.d.ts +135 -0
- package/dist/api/deps.d.ts.map +1 -0
- package/dist/api/deps.js +55 -0
- package/dist/api/deps.js.map +1 -0
- package/dist/api/errors.d.ts +35 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +63 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/index.d.ts +12 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +15 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/map-core-error.d.ts +19 -0
- package/dist/api/map-core-error.d.ts.map +1 -0
- package/dist/api/map-core-error.js +49 -0
- package/dist/api/map-core-error.js.map +1 -0
- package/dist/api/pagination.d.ts +19 -0
- package/dist/api/pagination.d.ts.map +1 -0
- package/dist/api/pagination.js +40 -0
- package/dist/api/pagination.js.map +1 -0
- package/dist/api/request.d.ts +93 -0
- package/dist/api/request.d.ts.map +1 -0
- package/dist/api/request.js +150 -0
- package/dist/api/request.js.map +1 -0
- package/dist/api/respond.d.ts +29 -0
- package/dist/api/respond.d.ts.map +1 -0
- package/dist/api/respond.js +59 -0
- package/dist/api/respond.js.map +1 -0
- package/dist/api/responses-schema.d.ts +158 -0
- package/dist/api/responses-schema.d.ts.map +1 -0
- package/dist/api/responses-schema.js +104 -0
- package/dist/api/responses-schema.js.map +1 -0
- package/dist/api/responses.d.ts +19 -0
- package/dist/api/responses.d.ts.map +1 -0
- package/dist/api/responses.js +21 -0
- package/dist/api/responses.js.map +1 -0
- package/dist/api/shaped.d.ts +23 -0
- package/dist/api/shaped.d.ts.map +1 -0
- package/dist/api/shaped.js +29 -0
- package/dist/api/shaped.js.map +1 -0
- package/dist/api/types.d.ts +41 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +12 -0
- package/dist/api/types.js.map +1 -0
- package/dist/compute/errors.d.ts +18 -0
- package/dist/compute/errors.d.ts.map +1 -0
- package/dist/compute/errors.js +21 -0
- package/dist/compute/errors.js.map +1 -0
- package/dist/compute/evict-changed-servers.d.ts +22 -0
- package/dist/compute/evict-changed-servers.d.ts.map +1 -0
- package/dist/compute/evict-changed-servers.js +31 -0
- package/dist/compute/evict-changed-servers.js.map +1 -0
- package/dist/compute/idempotency-http.d.ts +47 -0
- package/dist/compute/idempotency-http.d.ts.map +1 -0
- package/dist/compute/idempotency-http.js +51 -0
- package/dist/compute/idempotency-http.js.map +1 -0
- package/dist/compute/index.d.ts +5 -0
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +5 -0
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/resolve-server.d.ts +25 -0
- package/dist/compute/resolve-server.d.ts.map +1 -0
- package/dist/compute/resolve-server.js +35 -0
- package/dist/compute/resolve-server.js.map +1 -0
- package/dist/compute/server-config-write.d.ts +52 -0
- package/dist/compute/server-config-write.d.ts.map +1 -0
- package/dist/compute/server-config-write.js +100 -0
- package/dist/compute/server-config-write.js.map +1 -0
- package/dist/definitions/index.d.ts +1 -0
- package/dist/definitions/index.d.ts.map +1 -1
- package/dist/definitions/index.js +1 -0
- package/dist/definitions/index.js.map +1 -1
- package/dist/definitions/visibility.d.ts +74 -0
- package/dist/definitions/visibility.d.ts.map +1 -0
- package/dist/definitions/visibility.js +117 -0
- package/dist/definitions/visibility.js.map +1 -0
- package/dist/handlers/definitionVersions.d.ts +30 -0
- package/dist/handlers/definitionVersions.d.ts.map +1 -0
- package/dist/handlers/definitionVersions.js +93 -0
- package/dist/handlers/definitionVersions.js.map +1 -0
- package/dist/handlers/definitions.d.ts +35 -0
- package/dist/handlers/definitions.d.ts.map +1 -0
- package/dist/handlers/definitions.js +207 -0
- package/dist/handlers/definitions.js.map +1 -0
- package/dist/handlers/index.d.ts +27 -0
- package/dist/handlers/index.d.ts.map +1 -0
- package/dist/handlers/index.js +29 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/invite-lookup.d.ts +11 -0
- package/dist/handlers/invite-lookup.d.ts.map +1 -0
- package/dist/handlers/invite-lookup.js +20 -0
- package/dist/handlers/invite-lookup.js.map +1 -0
- package/dist/handlers/invites.d.ts +31 -0
- package/dist/handlers/invites.d.ts.map +1 -0
- package/dist/handlers/invites.js +148 -0
- package/dist/handlers/invites.js.map +1 -0
- package/dist/handlers/me.d.ts +8 -0
- package/dist/handlers/me.d.ts.map +1 -0
- package/dist/handlers/me.js +21 -0
- package/dist/handlers/me.js.map +1 -0
- package/dist/handlers/me.starred.d.ts +11 -0
- package/dist/handlers/me.starred.d.ts.map +1 -0
- package/dist/handlers/me.starred.js +21 -0
- package/dist/handlers/me.starred.js.map +1 -0
- package/dist/handlers/orgAssets.d.ts +13 -0
- package/dist/handlers/orgAssets.d.ts.map +1 -0
- package/dist/handlers/orgAssets.js +54 -0
- package/dist/handlers/orgAssets.js.map +1 -0
- package/dist/handlers/orgCompute.d.ts +16 -0
- package/dist/handlers/orgCompute.d.ts.map +1 -0
- package/dist/handlers/orgCompute.js +96 -0
- package/dist/handlers/orgCompute.js.map +1 -0
- package/dist/handlers/orgMembers.d.ts +29 -0
- package/dist/handlers/orgMembers.d.ts.map +1 -0
- package/dist/handlers/orgMembers.js +200 -0
- package/dist/handlers/orgMembers.js.map +1 -0
- package/dist/handlers/orgs.d.ts +16 -0
- package/dist/handlers/orgs.d.ts.map +1 -0
- package/dist/handlers/orgs.js +26 -0
- package/dist/handlers/orgs.js.map +1 -0
- package/dist/handlers/projectMembers.d.ts +15 -0
- package/dist/handlers/projectMembers.d.ts.map +1 -0
- package/dist/handlers/projectMembers.js +95 -0
- package/dist/handlers/projectMembers.js.map +1 -0
- package/dist/handlers/projects.d.ts +21 -0
- package/dist/handlers/projects.d.ts.map +1 -0
- package/dist/handlers/projects.js +164 -0
- package/dist/handlers/projects.js.map +1 -0
- package/dist/handlers/reclaim.d.ts +10 -0
- package/dist/handlers/reclaim.d.ts.map +1 -0
- package/dist/handlers/reclaim.js +38 -0
- package/dist/handlers/reclaim.js.map +1 -0
- package/dist/handlers/services.d.ts +22 -0
- package/dist/handlers/services.d.ts.map +1 -0
- package/dist/handlers/services.js +37 -0
- package/dist/handlers/services.js.map +1 -0
- package/dist/handlers/shareLinks.d.ts +23 -0
- package/dist/handlers/shareLinks.d.ts.map +1 -0
- package/dist/handlers/shareLinks.js +85 -0
- package/dist/handlers/shareLinks.js.map +1 -0
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +1 -0
- package/dist/http/index.js.map +1 -1
- package/dist/http/login-rate-limit.d.ts +72 -0
- package/dist/http/login-rate-limit.d.ts.map +1 -0
- package/dist/http/login-rate-limit.js +84 -0
- package/dist/http/login-rate-limit.js.map +1 -0
- package/dist/notifications/deliver-invite.d.ts +27 -0
- package/dist/notifications/deliver-invite.d.ts.map +1 -0
- package/dist/notifications/deliver-invite.js +32 -0
- package/dist/notifications/deliver-invite.js.map +1 -0
- package/dist/notifications/index.d.ts +4 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +4 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/smtp-config.d.ts +37 -0
- package/dist/notifications/smtp-config.d.ts.map +1 -0
- package/dist/notifications/smtp-config.js +41 -0
- package/dist/notifications/smtp-config.js.map +1 -0
- package/dist/notifications/smtp-provider.d.ts +18 -0
- package/dist/notifications/smtp-provider.d.ts.map +1 -0
- package/dist/notifications/smtp-provider.js +71 -0
- package/dist/notifications/smtp-provider.js.map +1 -0
- package/dist/ops/deploymentConfig.d.ts +73 -0
- package/dist/ops/deploymentConfig.d.ts.map +1 -0
- package/dist/ops/deploymentConfig.js +154 -0
- package/dist/ops/deploymentConfig.js.map +1 -0
- package/dist/ops/index.d.ts +1 -0
- package/dist/ops/index.d.ts.map +1 -1
- package/dist/ops/index.js +3 -2
- package/dist/ops/index.js.map +1 -1
- package/dist/organizations/index.d.ts +3 -0
- package/dist/organizations/index.d.ts.map +1 -0
- package/dist/organizations/index.js +5 -0
- package/dist/organizations/index.js.map +1 -0
- package/dist/organizations/list-all-members.d.ts +10 -0
- package/dist/organizations/list-all-members.d.ts.map +1 -0
- package/dist/organizations/list-all-members.js +36 -0
- package/dist/organizations/list-all-members.js.map +1 -0
- package/dist/organizations/org-asset-service.d.ts +39 -0
- package/dist/organizations/org-asset-service.d.ts.map +1 -0
- package/dist/organizations/org-asset-service.js +71 -0
- package/dist/organizations/org-asset-service.js.map +1 -0
- package/dist/projects/create-project.d.ts +30 -0
- package/dist/projects/create-project.d.ts.map +1 -0
- package/dist/projects/create-project.js +56 -0
- package/dist/projects/create-project.js.map +1 -0
- package/dist/projects/index.d.ts +2 -0
- package/dist/projects/index.d.ts.map +1 -0
- package/dist/projects/index.js +3 -0
- package/dist/projects/index.js.map +1 -0
- package/dist/providers/auth-lookup.d.ts +14 -0
- package/dist/providers/auth-lookup.d.ts.map +1 -0
- package/dist/providers/auth-lookup.js +34 -0
- package/dist/providers/auth-lookup.js.map +1 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/testing/harness.d.ts +168 -0
- package/dist/testing/harness.d.ts.map +1 -0
- package/dist/testing/harness.js +314 -0
- package/dist/testing/harness.js.map +1 -0
- package/dist/testing/index.d.ts +18 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +15 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/scenarios.d.ts +67 -0
- package/dist/testing/scenarios.d.ts.map +1 -0
- package/dist/testing/scenarios.js +104 -0
- package/dist/testing/scenarios.js.map +1 -0
- package/dist/testing/seed-adapter.d.ts +31 -0
- package/dist/testing/seed-adapter.d.ts.map +1 -0
- package/dist/testing/seed-adapter.js +19 -0
- package/dist/testing/seed-adapter.js.map +1 -0
- package/package.json +42 -3
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NoopLogger } from '@selvajs/platform';
|
|
2
|
+
import { readSmtpConfig } from './smtp-config.js';
|
|
3
|
+
/**
|
|
4
|
+
* SMTP transport. Any server works — a transactional provider, an internal
|
|
5
|
+
* relay, or a mailbox with an app password.
|
|
6
|
+
*
|
|
7
|
+
* Settings are read per send rather than cached, so a restart is the only
|
|
8
|
+
* thing needed to reconfigure and a rotated password takes effect without
|
|
9
|
+
* anyone remembering there is a cache.
|
|
10
|
+
*/
|
|
11
|
+
export class SmtpNotificationProvider {
|
|
12
|
+
env;
|
|
13
|
+
name = 'SMTP';
|
|
14
|
+
constructor(env) {
|
|
15
|
+
this.env = env;
|
|
16
|
+
}
|
|
17
|
+
async send(message, log = new NoopLogger()) {
|
|
18
|
+
let config;
|
|
19
|
+
try {
|
|
20
|
+
config = readSmtpConfig(this.env);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
log.error('SMTP is misconfigured', {
|
|
24
|
+
component: 'SmtpNotificationProvider',
|
|
25
|
+
error: err instanceof Error ? err.message : String(err)
|
|
26
|
+
});
|
|
27
|
+
return { status: 'failed', reason: 'misconfigured' };
|
|
28
|
+
}
|
|
29
|
+
if (!config)
|
|
30
|
+
return { status: 'not-configured' };
|
|
31
|
+
try {
|
|
32
|
+
// Lazy: an instance with no SMTP_HOST never pays to load nodemailer.
|
|
33
|
+
const { createTransport } = await import('nodemailer');
|
|
34
|
+
const transport = createTransport({
|
|
35
|
+
host: config.host,
|
|
36
|
+
port: config.port,
|
|
37
|
+
secure: config.secure,
|
|
38
|
+
auth: config.auth
|
|
39
|
+
});
|
|
40
|
+
await transport.sendMail({
|
|
41
|
+
from: config.from,
|
|
42
|
+
to: message.to,
|
|
43
|
+
subject: message.subject,
|
|
44
|
+
text: message.text,
|
|
45
|
+
html: message.html
|
|
46
|
+
});
|
|
47
|
+
return { status: 'sent' };
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
51
|
+
// Recipient is personal data — logged at warn only, as the minimum
|
|
52
|
+
// needed to diagnose a bounce. Never the subject or body.
|
|
53
|
+
log.warn('Failed to send mail', {
|
|
54
|
+
component: 'SmtpNotificationProvider',
|
|
55
|
+
to: message.to,
|
|
56
|
+
kind: message.kind,
|
|
57
|
+
reason
|
|
58
|
+
});
|
|
59
|
+
return { status: 'failed', reason };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
isConfigured() {
|
|
63
|
+
try {
|
|
64
|
+
return readSmtpConfig(this.env) !== null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=smtp-provider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"smtp-provider.js","sourceRoot":"","sources":["../../src/notifications/smtp-provider.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,UAAU,EAKV,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,cAAc,EAAgB,MAAM,kBAAkB,CAAC;AAEhE;;;;;;;GAOG;AACH,MAAM,OAAO,wBAAwB;IAGP;IAFpB,IAAI,GAAG,MAAM,CAAC;IAEvB,YAA6B,GAAY;QAAZ,QAAG,GAAH,GAAG,CAAS;IAAG,CAAC;IAE7C,KAAK,CAAC,IAAI,CAAC,OAAwB,EAAE,MAAe,IAAI,UAAU,EAAE;QACnE,IAAI,MAAM,CAAC;QACX,IAAI,CAAC;YACJ,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACnC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,GAAG,CAAC,KAAK,CAAC,uBAAuB,EAAE;gBAClC,SAAS,EAAE,0BAA0B;gBACrC,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;aACvD,CAAC,CAAC;YACH,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;QACtD,CAAC;QACD,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC;QAEjD,IAAI,CAAC;YACJ,qEAAqE;YACrE,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,CAAC;YACvD,MAAM,SAAS,GAAG,eAAe,CAAC;gBACjC,IAAI,EAAE,MAAM,CAAC,IAAI;gBACjB,IAAI,EAAE,MAAM,CAAC,IAAI;gBACjB,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,IAAI,EAAE,MAAM,CAAC,IAAI;aACjB,CAAC,CAAC;YAEH,MAAM,SAAS,CAAC,QAAQ,CAAC;gBACxB,IAAI,EAAE,MAAM,CAAC,IAAI;gBACjB,EAAE,EAAE,OAAO,CAAC,EAAE;gBACd,OAAO,EAAE,OAAO,CAAC,OAAO;gBACxB,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,IAAI,EAAE,OAAO,CAAC,IAAI;aAClB,CAAC,CAAC;YACH,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,MAAM,MAAM,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAChE,mEAAmE;YACnE,0DAA0D;YAC1D,GAAG,CAAC,IAAI,CAAC,qBAAqB,EAAE;gBAC/B,SAAS,EAAE,0BAA0B;gBACrC,EAAE,EAAE,OAAO,CAAC,EAAE;gBACd,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,MAAM;aACN,CAAC,CAAC;YACH,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;QACrC,CAAC;IACF,CAAC;IAED,YAAY;QACX,IAAI,CAAC;YACJ,OAAO,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC;QAC1C,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,KAAK,CAAC;QACd,CAAC;IACF,CAAC;CACD"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse-proxy and body-limit configuration rules, evaluated against a plain
|
|
3
|
+
* env bag. Pure predicates — no filesystem, no network, no process.
|
|
4
|
+
*
|
|
5
|
+
* These settings are read by adapter-node, not by Selva, so getting them wrong
|
|
6
|
+
* is invisible: nothing throws, nothing logs, and the deployment serves traffic
|
|
7
|
+
* while a whole class of requests fails. `selva doctor` reports them on the
|
|
8
|
+
* host; the admin health panel runs the same rules so an operator who never
|
|
9
|
+
* opens a shell still sees them.
|
|
10
|
+
*
|
|
11
|
+
* `packages/cli/src/checks/config.js` carries a duplicate of every rule here.
|
|
12
|
+
* Duplicated because the CLI is dependency-free by design — it scaffolds the
|
|
13
|
+
* deployment that installs this runtime, so it cannot import from it. A shared
|
|
14
|
+
* fixture table (`ops/__tests__/deployment-config-fixtures.js`) is asserted by
|
|
15
|
+
* both test suites, so a rule changed on one side fails CI on the other.
|
|
16
|
+
*/
|
|
17
|
+
/** Neutral severity. Callers map it onto their own presentation. */
|
|
18
|
+
export type ConfigVerdict = 'ok' | 'warn' | 'fail';
|
|
19
|
+
export interface ConfigFinding {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
verdict: ConfigVerdict;
|
|
23
|
+
summary: string;
|
|
24
|
+
/** Concrete next step. Absent when the verdict is `ok`. */
|
|
25
|
+
remediation?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Just the vars these rules read. Values are raw strings, as they come from `.env`. */
|
|
28
|
+
export interface DeploymentEnv {
|
|
29
|
+
ORIGIN?: string;
|
|
30
|
+
ADDRESS_HEADER?: string;
|
|
31
|
+
XFF_DEPTH?: string;
|
|
32
|
+
BODY_SIZE_LIMIT?: string;
|
|
33
|
+
COMPUTE_REQUEST_MAX_BYTES?: string;
|
|
34
|
+
[key: string]: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse a `BODY_SIZE_LIMIT` the way adapter-node does, so this agrees with the
|
|
38
|
+
* thing that actually rejects the request.
|
|
39
|
+
*
|
|
40
|
+
* The quirk is load-bearing: adapter-node reads only the LAST character as the
|
|
41
|
+
* suffix, so `60mb` parses as the digits `60m` and happens to mean 60 MB, while
|
|
42
|
+
* `60xy` silently becomes NaN. Returns bytes, or null when the value is one
|
|
43
|
+
* adapter-node can't use.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseBodySizeLimit(raw: string | undefined | null): number | null;
|
|
46
|
+
/**
|
|
47
|
+
* BODY_SIZE_LIMIT has to clear the compute request cap, or adapter-node's global
|
|
48
|
+
* backstop rejects the upload first — with a non-JSON body the app never sees
|
|
49
|
+
* and cannot log. Unset is the dangerous case, not a neutral one: adapter-node
|
|
50
|
+
* falls back to 512 KB, so every upload 413s on a deployment that looks fine.
|
|
51
|
+
*/
|
|
52
|
+
export declare function checkBodySizeLimit(env: DeploymentEnv): ConfigFinding;
|
|
53
|
+
/**
|
|
54
|
+
* `ADDRESS_HEADER` / `XFF_DEPTH` are read by adapter-node, not by Selva, and
|
|
55
|
+
* unset they are invisible: nothing fails, nothing logs, and the deployment
|
|
56
|
+
* looks healthy.
|
|
57
|
+
*
|
|
58
|
+
* What actually happens is that `getClientAddress()` returns the socket peer,
|
|
59
|
+
* which behind a reverse proxy is the proxy — `127.0.0.1` for every request
|
|
60
|
+
* from every user. Login rate limiting keys on that, so the whole instance
|
|
61
|
+
* shares one bucket: five failed logins from anywhere return 429 to everyone,
|
|
62
|
+
* and only a *successful* login clears a bucket, which nobody can then reach.
|
|
63
|
+
*
|
|
64
|
+
* `ORIGIN` is the proxy tell. It is only needed when the app is served under a
|
|
65
|
+
* public URL it can't derive from its own socket, so its presence means a proxy
|
|
66
|
+
* is terminating requests. Without it we say nothing: on a directly-reachable
|
|
67
|
+
* app these settings are a footgun, since `X-Forwarded-For` is client-supplied
|
|
68
|
+
* and anyone could then pick their own rate-limit bucket.
|
|
69
|
+
*/
|
|
70
|
+
export declare function checkClientAddress(env: DeploymentEnv): ConfigFinding;
|
|
71
|
+
/** Every deployment-config rule, in report order. */
|
|
72
|
+
export declare function checkDeploymentConfig(env: DeploymentEnv): ConfigFinding[];
|
|
73
|
+
//# sourceMappingURL=deploymentConfig.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploymentConfig.d.ts","sourceRoot":"","sources":["../../src/ops/deploymentConfig.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,oEAAoE;AACpE,MAAM,MAAM,aAAa,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wFAAwF;AACxF,MAAM,WAAW,aAAa;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,yBAAyB,CAAC,EAAE,MAAM,CAAC;IAKnC,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAUhF;AAOD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,aAAa,GAAG,aAAa,CA0CpE;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,aAAa,GAAG,aAAa,CAwDpE;AAED,qDAAqD;AACrD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,aAAa,GAAG,aAAa,EAAE,CAEzE"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse-proxy and body-limit configuration rules, evaluated against a plain
|
|
3
|
+
* env bag. Pure predicates — no filesystem, no network, no process.
|
|
4
|
+
*
|
|
5
|
+
* These settings are read by adapter-node, not by Selva, so getting them wrong
|
|
6
|
+
* is invisible: nothing throws, nothing logs, and the deployment serves traffic
|
|
7
|
+
* while a whole class of requests fails. `selva doctor` reports them on the
|
|
8
|
+
* host; the admin health panel runs the same rules so an operator who never
|
|
9
|
+
* opens a shell still sees them.
|
|
10
|
+
*
|
|
11
|
+
* `packages/cli/src/checks/config.js` carries a duplicate of every rule here.
|
|
12
|
+
* Duplicated because the CLI is dependency-free by design — it scaffolds the
|
|
13
|
+
* deployment that installs this runtime, so it cannot import from it. A shared
|
|
14
|
+
* fixture table (`ops/__tests__/deployment-config-fixtures.js`) is asserted by
|
|
15
|
+
* both test suites, so a rule changed on one side fails CI on the other.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Parse a `BODY_SIZE_LIMIT` the way adapter-node does, so this agrees with the
|
|
19
|
+
* thing that actually rejects the request.
|
|
20
|
+
*
|
|
21
|
+
* The quirk is load-bearing: adapter-node reads only the LAST character as the
|
|
22
|
+
* suffix, so `60mb` parses as the digits `60m` and happens to mean 60 MB, while
|
|
23
|
+
* `60xy` silently becomes NaN. Returns bytes, or null when the value is one
|
|
24
|
+
* adapter-node can't use.
|
|
25
|
+
*/
|
|
26
|
+
export function parseBodySizeLimit(raw) {
|
|
27
|
+
if (raw == null || raw === '')
|
|
28
|
+
return null;
|
|
29
|
+
const value = String(raw).trim();
|
|
30
|
+
if (value === '')
|
|
31
|
+
return null;
|
|
32
|
+
const units = { K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 };
|
|
33
|
+
const multiplier = units[value.at(-1).toUpperCase()] ?? 1;
|
|
34
|
+
const bytes = Number(multiplier !== 1 ? value.slice(0, -1) : value) * multiplier;
|
|
35
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
36
|
+
return null;
|
|
37
|
+
return bytes;
|
|
38
|
+
}
|
|
39
|
+
/** The app's own default for COMPUTE_REQUEST_MAX_BYTES (compute/limits.ts). */
|
|
40
|
+
const COMPUTE_REQUEST_DEFAULT_BYTES = 256 * 1024 * 1024;
|
|
41
|
+
const asMb = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
42
|
+
/**
|
|
43
|
+
* BODY_SIZE_LIMIT has to clear the compute request cap, or adapter-node's global
|
|
44
|
+
* backstop rejects the upload first — with a non-JSON body the app never sees
|
|
45
|
+
* and cannot log. Unset is the dangerous case, not a neutral one: adapter-node
|
|
46
|
+
* falls back to 512 KB, so every upload 413s on a deployment that looks fine.
|
|
47
|
+
*/
|
|
48
|
+
export function checkBodySizeLimit(env) {
|
|
49
|
+
const base = { id: 'body-size-limit', label: 'Upload size limit' };
|
|
50
|
+
const raw = env.BODY_SIZE_LIMIT;
|
|
51
|
+
const requestCap = Number(env.COMPUTE_REQUEST_MAX_BYTES) || COMPUTE_REQUEST_DEFAULT_BYTES;
|
|
52
|
+
if (raw == null || raw === '') {
|
|
53
|
+
return {
|
|
54
|
+
...base,
|
|
55
|
+
verdict: 'fail',
|
|
56
|
+
summary: 'BODY_SIZE_LIMIT is unset — adapter-node falls back to 512 KB, so uploads over ' +
|
|
57
|
+
'that fail with an opaque 413 no app log records.',
|
|
58
|
+
remediation: `Set BODY_SIZE_LIMIT to at least ${asMb(requestCap)} (the compute request cap) and restart.`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const bytes = parseBodySizeLimit(raw);
|
|
62
|
+
if (bytes === null) {
|
|
63
|
+
return {
|
|
64
|
+
...base,
|
|
65
|
+
verdict: 'fail',
|
|
66
|
+
summary: `BODY_SIZE_LIMIT="${raw}" — adapter-node cannot parse this and throws on boot.`,
|
|
67
|
+
remediation: 'Use a byte count or a single K/M/G suffix, e.g. "256M".'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (bytes < requestCap) {
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
verdict: 'warn',
|
|
74
|
+
summary: `BODY_SIZE_LIMIT=${raw} (${asMb(bytes)}) is below COMPUTE_REQUEST_MAX_BYTES ` +
|
|
75
|
+
`(${asMb(requestCap)}) — the global cap rejects first, making the compute cap dead config.`,
|
|
76
|
+
remediation: `Raise BODY_SIZE_LIMIT to at least ${asMb(requestCap)} and restart.`
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
...base,
|
|
81
|
+
verdict: 'ok',
|
|
82
|
+
summary: `BODY_SIZE_LIMIT=${raw} clears the compute request cap.`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `ADDRESS_HEADER` / `XFF_DEPTH` are read by adapter-node, not by Selva, and
|
|
87
|
+
* unset they are invisible: nothing fails, nothing logs, and the deployment
|
|
88
|
+
* looks healthy.
|
|
89
|
+
*
|
|
90
|
+
* What actually happens is that `getClientAddress()` returns the socket peer,
|
|
91
|
+
* which behind a reverse proxy is the proxy — `127.0.0.1` for every request
|
|
92
|
+
* from every user. Login rate limiting keys on that, so the whole instance
|
|
93
|
+
* shares one bucket: five failed logins from anywhere return 429 to everyone,
|
|
94
|
+
* and only a *successful* login clears a bucket, which nobody can then reach.
|
|
95
|
+
*
|
|
96
|
+
* `ORIGIN` is the proxy tell. It is only needed when the app is served under a
|
|
97
|
+
* public URL it can't derive from its own socket, so its presence means a proxy
|
|
98
|
+
* is terminating requests. Without it we say nothing: on a directly-reachable
|
|
99
|
+
* app these settings are a footgun, since `X-Forwarded-For` is client-supplied
|
|
100
|
+
* and anyone could then pick their own rate-limit bucket.
|
|
101
|
+
*/
|
|
102
|
+
export function checkClientAddress(env) {
|
|
103
|
+
const base = { id: 'client-address', label: 'Client IP behind proxy' };
|
|
104
|
+
const header = env.ADDRESS_HEADER;
|
|
105
|
+
const depth = env.XFF_DEPTH;
|
|
106
|
+
if (!env.ORIGIN) {
|
|
107
|
+
return {
|
|
108
|
+
...base,
|
|
109
|
+
verdict: 'ok',
|
|
110
|
+
summary: 'ADDRESS_HEADER is not needed — ORIGIN is unset, so no reverse proxy is in play.'
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (!header) {
|
|
114
|
+
return {
|
|
115
|
+
...base,
|
|
116
|
+
verdict: 'fail',
|
|
117
|
+
summary: 'ADDRESS_HEADER is unset while ORIGIN is set — every request looks like it came from ' +
|
|
118
|
+
'the proxy, so all users share ONE login rate-limit bucket. Five failed logins from ' +
|
|
119
|
+
'anyone locks out the whole instance for 15 minutes.',
|
|
120
|
+
remediation: 'Set ADDRESS_HEADER=X-Forwarded-For and XFF_DEPTH=<number of proxies>, then restart. ' +
|
|
121
|
+
'To count your proxies, see docs/self-hosting/deployment/reverse-proxy.md.'
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (header.toLowerCase() === 'x-forwarded-for' && !depth) {
|
|
125
|
+
return {
|
|
126
|
+
...base,
|
|
127
|
+
verdict: 'warn',
|
|
128
|
+
summary: `ADDRESS_HEADER=${header} but XFF_DEPTH is unset. X-Forwarded-For is a ` +
|
|
129
|
+
'client-appendable list, so a caller can prepend fake entries — adapter-node needs ' +
|
|
130
|
+
'the proxy count to know which entry is real.',
|
|
131
|
+
remediation: 'Set XFF_DEPTH=1 for a single reverse proxy, 2 if a CDN or load balancer sits in ' +
|
|
132
|
+
'front of it. Unsure? docs/self-hosting/deployment/reverse-proxy.md shows how to ' +
|
|
133
|
+
'read the header the app actually receives.'
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (depth !== undefined && !/^[1-9][0-9]*$/.test(depth)) {
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
verdict: 'fail',
|
|
140
|
+
summary: `XFF_DEPTH="${depth}" is not a positive integer.`,
|
|
141
|
+
remediation: 'It counts proxies from the outside in — use 1 for a single reverse proxy.'
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
verdict: 'ok',
|
|
147
|
+
summary: `ADDRESS_HEADER=${header}${depth ? ` (XFF_DEPTH=${depth})` : ''}.`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Every deployment-config rule, in report order. */
|
|
151
|
+
export function checkDeploymentConfig(env) {
|
|
152
|
+
return [checkClientAddress(env), checkBodySizeLimit(env)];
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=deploymentConfig.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploymentConfig.js","sourceRoot":"","sources":["../../src/ops/deploymentConfig.ts"],"names":[],"mappings":"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"}
|
package/dist/ops/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { parseSemver, isNewer, compareCore, satisfiesRange, type ReleaseChannel } from './semver.js';
|
|
2
|
+
export { checkClientAddress, checkBodySizeLimit, checkDeploymentConfig, parseBodySizeLimit, type ConfigFinding, type ConfigVerdict, type DeploymentEnv } from './deploymentConfig.js';
|
|
2
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/ops/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACN,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EACd,KAAK,cAAc,EACnB,MAAM,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACN,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EACd,KAAK,cAAc,EACnB,MAAM,aAAa,CAAC;AAErB,OAAO,EACN,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,kBAAkB,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,MAAM,uBAAuB,CAAC"}
|
package/dist/ops/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Deployment/ops helpers — channel-aware semver comparison
|
|
2
|
-
//
|
|
1
|
+
// Deployment/ops helpers — channel-aware semver comparison, engines.node range
|
|
2
|
+
// checks, and the reverse-proxy/body-limit configuration rules.
|
|
3
3
|
export { parseSemver, isNewer, compareCore, satisfiesRange } from './semver.js';
|
|
4
|
+
export { checkClientAddress, checkBodySizeLimit, checkDeploymentConfig, parseBodySizeLimit } from './deploymentConfig.js';
|
|
4
5
|
//# sourceMappingURL=index.js.map
|
package/dist/ops/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,gEAAgE;AAEhE,OAAO,EACN,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EAEd,MAAM,aAAa,CAAC;AAErB,OAAO,EACN,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,kBAAkB,EAIlB,MAAM,uBAAuB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/organizations/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/organizations/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,iCAAiC;AAEjC,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ILogger, IOrgStore, OrgMember } from '@selvajs/platform';
|
|
2
|
+
/**
|
|
3
|
+
* Drain every membership page for an org. The admin users pages merge
|
|
4
|
+
* per-user org permissions into these rows; a truncated list would render
|
|
5
|
+
* those users as permissionless, and the permission-toggle UI builds its
|
|
6
|
+
* PATCH payload from the displayed state — so truncation here could silently
|
|
7
|
+
* wipe a user's org permissions on the next edit.
|
|
8
|
+
*/
|
|
9
|
+
export declare function listAllOrgMembers(orgs: IOrgStore, orgId: string, log?: ILogger): Promise<OrgMember[]>;
|
|
10
|
+
//# sourceMappingURL=list-all-members.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list-all-members.d.ts","sourceRoot":"","sources":["../../src/organizations/list-all-members.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAQvE;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACtC,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,MAAM,EACb,GAAG,CAAC,EAAE,OAAO,GACX,OAAO,CAAC,SAAS,EAAE,CAAC,CAqBtB"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SYSTEM_CONTEXT } from '@selvajs/platform';
|
|
2
|
+
const PAGE_LIMIT = 200;
|
|
3
|
+
// Runaway guard against an adapter that returns a non-advancing cursor —
|
|
4
|
+
// 100 pages = 20k members, far past any real org on this product.
|
|
5
|
+
const MAX_PAGES = 100;
|
|
6
|
+
/**
|
|
7
|
+
* Drain every membership page for an org. The admin users pages merge
|
|
8
|
+
* per-user org permissions into these rows; a truncated list would render
|
|
9
|
+
* those users as permissionless, and the permission-toggle UI builds its
|
|
10
|
+
* PATCH payload from the displayed state — so truncation here could silently
|
|
11
|
+
* wipe a user's org permissions on the next edit.
|
|
12
|
+
*/
|
|
13
|
+
export async function listAllOrgMembers(orgs, orgId, log) {
|
|
14
|
+
const members = [];
|
|
15
|
+
let cursor;
|
|
16
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
17
|
+
const result = await orgs.listOrgMembers(SYSTEM_CONTEXT, orgId, {
|
|
18
|
+
limit: PAGE_LIMIT,
|
|
19
|
+
cursor
|
|
20
|
+
});
|
|
21
|
+
members.push(...result.items);
|
|
22
|
+
cursor = result.nextCursor;
|
|
23
|
+
if (!cursor)
|
|
24
|
+
return members;
|
|
25
|
+
}
|
|
26
|
+
// Injected rather than resolved: hitting the cap is the one outcome a caller
|
|
27
|
+
// must be able to see, and a package that reached for a host's logger could
|
|
28
|
+
// not be used by a host that has a different one.
|
|
29
|
+
log?.warn('listAllOrgMembers hit the page cap — member list may be incomplete', {
|
|
30
|
+
component: 'admin',
|
|
31
|
+
orgId,
|
|
32
|
+
pages: MAX_PAGES
|
|
33
|
+
});
|
|
34
|
+
return members;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=list-all-members.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list-all-members.js","sourceRoot":"","sources":["../../src/organizations/list-all-members.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,MAAM,UAAU,GAAG,GAAG,CAAC;AACvB,yEAAyE;AACzE,kEAAkE;AAClE,MAAM,SAAS,GAAG,GAAG,CAAC;AAEtB;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACtC,IAAe,EACf,KAAa,EACb,GAAa;IAEb,MAAM,OAAO,GAAgB,EAAE,CAAC;IAChC,IAAI,MAA0B,CAAC;IAC/B,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC;QAC7C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,cAAc,EAAE,KAAK,EAAE;YAC/D,KAAK,EAAE,UAAU;YACjB,MAAM;SACN,CAAC,CAAC;QACH,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC9B,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC;QAC3B,IAAI,CAAC,MAAM;YAAE,OAAO,OAAO,CAAC;IAC7B,CAAC;IACD,6EAA6E;IAC7E,4EAA4E;IAC5E,kDAAkD;IAClD,GAAG,EAAE,IAAI,CAAC,oEAAoE,EAAE;QAC/E,SAAS,EAAE,OAAO;QAClB,KAAK;QACL,KAAK,EAAE,SAAS;KAChB,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { IOrgStore, IStorageProvider, OrgAssetKind, RequestContext } from '@selvajs/platform';
|
|
2
|
+
/**
|
|
3
|
+
* Orchestrates org-asset writes that span IStorageProvider + IOrgStore — one
|
|
4
|
+
* service for every asset kind (logo, favicon, …).
|
|
5
|
+
*
|
|
6
|
+
* Every upload is rasterized to WebP by the shared transcoder inside
|
|
7
|
+
* `IStorageProvider.put`, so the stored blob is always
|
|
8
|
+
* `orgs/{id}/branding/{kind}.webp` (the `branding/` segment marks the public
|
|
9
|
+
* asset class — see `classifyAssetPath`). SVG inputs are flattened to a
|
|
10
|
+
* 1200px raster — no vector blob is persisted, which keeps the served bytes
|
|
11
|
+
* XSS-free without a sanitizer. Re-uploads overwrite in place. The org's
|
|
12
|
+
* `assets` map is the source of truth for which kinds exist and their public
|
|
13
|
+
* URLs; we read-merge-write it so concurrent edits to *different* kinds don't
|
|
14
|
+
* clobber each other within a kind.
|
|
15
|
+
*
|
|
16
|
+
* Re-uploads overwrite the same path, so the stored URL would be identical
|
|
17
|
+
* across replaces and a cached browser/CDN would keep serving the old bytes.
|
|
18
|
+
* The content-derived `?v=` token (`withCacheBust`) gives a *different* image a
|
|
19
|
+
* *different* URL, while an identical re-upload keeps its URL and stays cached.
|
|
20
|
+
*/
|
|
21
|
+
export declare class OrgAssetService {
|
|
22
|
+
private orgs;
|
|
23
|
+
private storage;
|
|
24
|
+
constructor(orgs: IOrgStore, storage: IStorageProvider);
|
|
25
|
+
/**
|
|
26
|
+
* Store `kind`'s blob and point the org's `assets[kind]` at its public URL.
|
|
27
|
+
* `contentType` must be the *original* upload type (e.g. `image/svg+xml`) so
|
|
28
|
+
* the transcoder recognizes the bytes as an image. Returns the public URL.
|
|
29
|
+
*/
|
|
30
|
+
saveAsset(ctx: RequestContext, orgId: string, kind: OrgAssetKind, data: Uint8Array, contentType: string): Promise<string>;
|
|
31
|
+
/** Remove `kind`'s blob and drop it from the org's `assets` map. No-op-safe. */
|
|
32
|
+
removeAsset(ctx: RequestContext, orgId: string, kind: OrgAssetKind): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Read the org's current `assets` and return a copy with `kind` set to `url`
|
|
35
|
+
* (or removed when `url` is undefined). Keeps the other kinds intact.
|
|
36
|
+
*/
|
|
37
|
+
private mergedAssets;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=org-asset-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"org-asset-service.d.ts","sourceRoot":"","sources":["../../src/organizations/org-asset-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGnG;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,eAAe;IAE1B,OAAO,CAAC,IAAI;IACZ,OAAO,CAAC,OAAO;gBADP,IAAI,EAAE,SAAS,EACf,OAAO,EAAE,gBAAgB;IAGlC;;;;OAIG;IACG,SAAS,CACd,GAAG,EAAE,cAAc,EACnB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE,UAAU,EAChB,WAAW,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC;IAelB,gFAAgF;IAC1E,WAAW,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAOxF;;;OAGG;YACW,YAAY;CAa1B"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { orgPaths, ProviderError, withCacheBust } from '@selvajs/platform';
|
|
3
|
+
/**
|
|
4
|
+
* Orchestrates org-asset writes that span IStorageProvider + IOrgStore — one
|
|
5
|
+
* service for every asset kind (logo, favicon, …).
|
|
6
|
+
*
|
|
7
|
+
* Every upload is rasterized to WebP by the shared transcoder inside
|
|
8
|
+
* `IStorageProvider.put`, so the stored blob is always
|
|
9
|
+
* `orgs/{id}/branding/{kind}.webp` (the `branding/` segment marks the public
|
|
10
|
+
* asset class — see `classifyAssetPath`). SVG inputs are flattened to a
|
|
11
|
+
* 1200px raster — no vector blob is persisted, which keeps the served bytes
|
|
12
|
+
* XSS-free without a sanitizer. Re-uploads overwrite in place. The org's
|
|
13
|
+
* `assets` map is the source of truth for which kinds exist and their public
|
|
14
|
+
* URLs; we read-merge-write it so concurrent edits to *different* kinds don't
|
|
15
|
+
* clobber each other within a kind.
|
|
16
|
+
*
|
|
17
|
+
* Re-uploads overwrite the same path, so the stored URL would be identical
|
|
18
|
+
* across replaces and a cached browser/CDN would keep serving the old bytes.
|
|
19
|
+
* The content-derived `?v=` token (`withCacheBust`) gives a *different* image a
|
|
20
|
+
* *different* URL, while an identical re-upload keeps its URL and stays cached.
|
|
21
|
+
*/
|
|
22
|
+
export class OrgAssetService {
|
|
23
|
+
orgs;
|
|
24
|
+
storage;
|
|
25
|
+
constructor(orgs, storage) {
|
|
26
|
+
this.orgs = orgs;
|
|
27
|
+
this.storage = storage;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Store `kind`'s blob and point the org's `assets[kind]` at its public URL.
|
|
31
|
+
* `contentType` must be the *original* upload type (e.g. `image/svg+xml`) so
|
|
32
|
+
* the transcoder recognizes the bytes as an image. Returns the public URL.
|
|
33
|
+
*/
|
|
34
|
+
async saveAsset(ctx, orgId, kind, data, contentType) {
|
|
35
|
+
const path = orgPaths.asset(orgId, kind);
|
|
36
|
+
await this.storage.put(path, data, contentType);
|
|
37
|
+
// Content-derived token, not a timestamp: an identical re-upload to the
|
|
38
|
+
// same path keeps the same URL and stays cached; a changed image gets a
|
|
39
|
+
// new URL and refetches immediately. 8 hex chars is enough to distinguish
|
|
40
|
+
// versions without bloating the URL.
|
|
41
|
+
const token = createHash('sha256').update(data).digest('hex').slice(0, 8);
|
|
42
|
+
const url = withCacheBust(this.storage.getPublicUrl(path), token);
|
|
43
|
+
await this.orgs.updateOrg(ctx, orgId, {
|
|
44
|
+
assets: await this.mergedAssets(ctx, orgId, kind, url)
|
|
45
|
+
});
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
/** Remove `kind`'s blob and drop it from the org's `assets` map. No-op-safe. */
|
|
49
|
+
async removeAsset(ctx, orgId, kind) {
|
|
50
|
+
await this.storage.delete(orgPaths.asset(orgId, kind));
|
|
51
|
+
await this.orgs.updateOrg(ctx, orgId, {
|
|
52
|
+
assets: await this.mergedAssets(ctx, orgId, kind, undefined)
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read the org's current `assets` and return a copy with `kind` set to `url`
|
|
57
|
+
* (or removed when `url` is undefined). Keeps the other kinds intact.
|
|
58
|
+
*/
|
|
59
|
+
async mergedAssets(ctx, orgId, kind, url) {
|
|
60
|
+
const org = await this.orgs.getOrg(ctx, orgId);
|
|
61
|
+
if (!org)
|
|
62
|
+
throw new ProviderError(`Org '${orgId}' not found`, 404);
|
|
63
|
+
const next = { ...(org.assets ?? {}) };
|
|
64
|
+
if (url === undefined)
|
|
65
|
+
delete next[kind];
|
|
66
|
+
else
|
|
67
|
+
next[kind] = url;
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=org-asset-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"org-asset-service.js","sourceRoot":"","sources":["../../src/organizations/org-asset-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAE3E;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,OAAO,eAAe;IAElB;IACA;IAFT,YACS,IAAe,EACf,OAAyB;QADzB,SAAI,GAAJ,IAAI,CAAW;QACf,YAAO,GAAP,OAAO,CAAkB;IAC/B,CAAC;IAEJ;;;;OAIG;IACH,KAAK,CAAC,SAAS,CACd,GAAmB,EACnB,KAAa,EACb,IAAkB,EAClB,IAAgB,EAChB,WAAmB;QAEnB,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QACzC,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,WAAW,CAAC,CAAC;QAChD,wEAAwE;QACxE,wEAAwE;QACxE,0EAA0E;QAC1E,qCAAqC;QACrC,MAAM,KAAK,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAC1E,MAAM,GAAG,GAAG,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,KAAK,CAAC,CAAC;QAClE,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,KAAK,EAAE;YACrC,MAAM,EAAE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC;SACtD,CAAC,CAAC;QACH,OAAO,GAAG,CAAC;IACZ,CAAC;IAED,gFAAgF;IAChF,KAAK,CAAC,WAAW,CAAC,GAAmB,EAAE,KAAa,EAAE,IAAkB;QACvE,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;QACvD,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,KAAK,EAAE;YACrC,MAAM,EAAE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,CAAC;SAC5D,CAAC,CAAC;IACJ,CAAC;IAED;;;OAGG;IACK,KAAK,CAAC,YAAY,CACzB,GAAmB,EACnB,KAAa,EACb,IAAkB,EAClB,GAAuB;QAEvB,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QAC/C,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,aAAa,CAAC,QAAQ,KAAK,aAAa,EAAE,GAAG,CAAC,CAAC;QACnE,MAAM,IAAI,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,EAA4B,CAAC;QACjE,IAAI,GAAG,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC;;YACpC,IAAI,CAAC,IAAI,CAAC,GAAG,GAAG,CAAC;QACtB,OAAO,IAAI,CAAC;IACb,CAAC;CACD"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Project, RequestContext } from '@selvajs/platform';
|
|
2
|
+
/**
|
|
3
|
+
* Fields the caller decides. `slug`, `createdAt`, `updatedAt` and `deletedAt`
|
|
4
|
+
* are derived here so the two create routes cannot disagree about them.
|
|
5
|
+
*/
|
|
6
|
+
export type ProjectDraft = Omit<Project, 'slug' | 'createdAt' | 'updatedAt' | 'deletedAt'>;
|
|
7
|
+
export interface CreateProjectOptions {
|
|
8
|
+
/** Context the store writes under — `SYSTEM_CONTEXT` for the admin route. */
|
|
9
|
+
writeCtx: RequestContext;
|
|
10
|
+
/** Slug stem when the name slugifies to nothing. */
|
|
11
|
+
fallbackSlug: string;
|
|
12
|
+
/** Spliced into the 409 on a duplicate name. */
|
|
13
|
+
conflictScope: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Create a project, retrying on slug collision.
|
|
17
|
+
*
|
|
18
|
+
* The retry is not a nicety: the caller may not be able to *see* a colliding
|
|
19
|
+
* project under RLS, so a pre-flight `getProjectBySlug` cannot decide it — the
|
|
20
|
+
* unique index is the only source of truth.
|
|
21
|
+
*
|
|
22
|
+
* `/api/v1/projects` and `/api/admin/projects` both create projects and both
|
|
23
|
+
* carried a copy of this loop. The copies had already drifted on three points
|
|
24
|
+
* (error re-throw, slug stem, flag validation), which is what makes this the
|
|
25
|
+
* one place in the access surface where duplication caused real divergence.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createProjectWithUniqueSlug(store: {
|
|
28
|
+
createProject(ctx: RequestContext, project: Project): Promise<unknown>;
|
|
29
|
+
}, draft: ProjectDraft, { writeCtx, fallbackSlug, conflictScope }: CreateProjectOptions): Promise<Project>;
|
|
30
|
+
//# sourceMappingURL=create-project.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-project.d.ts","sourceRoot":"","sources":["../../src/projects/create-project.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAyBjE;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC,CAAC;AAE3F,MAAM,WAAW,oBAAoB;IACpC,6EAA6E;IAC7E,QAAQ,EAAE,cAAc,CAAC;IACzB,oDAAoD;IACpD,YAAY,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,aAAa,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,2BAA2B,CAChD,KAAK,EAAE;IACN,aAAa,CAAC,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACvE,EACD,KAAK,EAAE,YAAY,EACnB,EAAE,QAAQ,EAAE,YAAY,EAAE,aAAa,EAAE,EAAE,oBAAoB,GAC7D,OAAO,CAAC,OAAO,CAAC,CAmClB"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ProviderError, slugify } from '@selvajs/platform';
|
|
2
|
+
import { apiError, ApiErrorCode } from '../api/errors.js';
|
|
3
|
+
const MAX_SLUG_ATTEMPTS = 25;
|
|
4
|
+
// Both predicates match a Postgres constraint name inside an error message.
|
|
5
|
+
// That is fragile enough that it must exist once: a renamed constraint breaks
|
|
6
|
+
// silently — the retry loop stops retrying and a collision surfaces as a 500.
|
|
7
|
+
function isSlugConflict(err) {
|
|
8
|
+
return (err instanceof ProviderError &&
|
|
9
|
+
err.statusCode === 409 &&
|
|
10
|
+
/projects_org_id_slug_key/.test(err.message));
|
|
11
|
+
}
|
|
12
|
+
function isNameConflict(err) {
|
|
13
|
+
return (err instanceof ProviderError &&
|
|
14
|
+
err.statusCode === 409 &&
|
|
15
|
+
/projects_org_name_unique/.test(err.message));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a project, retrying on slug collision.
|
|
19
|
+
*
|
|
20
|
+
* The retry is not a nicety: the caller may not be able to *see* a colliding
|
|
21
|
+
* project under RLS, so a pre-flight `getProjectBySlug` cannot decide it — the
|
|
22
|
+
* unique index is the only source of truth.
|
|
23
|
+
*
|
|
24
|
+
* `/api/v1/projects` and `/api/admin/projects` both create projects and both
|
|
25
|
+
* carried a copy of this loop. The copies had already drifted on three points
|
|
26
|
+
* (error re-throw, slug stem, flag validation), which is what makes this the
|
|
27
|
+
* one place in the access surface where duplication caused real divergence.
|
|
28
|
+
*/
|
|
29
|
+
export async function createProjectWithUniqueSlug(store, draft, { writeCtx, fallbackSlug, conflictScope }) {
|
|
30
|
+
const baseSlug = slugify(draft.name) || fallbackSlug;
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
for (let attempt = 0; attempt < MAX_SLUG_ATTEMPTS; attempt++) {
|
|
33
|
+
const project = {
|
|
34
|
+
...draft,
|
|
35
|
+
slug: attempt === 0 ? baseSlug : `${baseSlug}-${attempt + 1}`,
|
|
36
|
+
createdAt: now,
|
|
37
|
+
updatedAt: now,
|
|
38
|
+
deletedAt: null
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
await store.createProject(writeCtx, project);
|
|
42
|
+
return project;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (isNameConflict(err)) {
|
|
46
|
+
apiError(409, ApiErrorCode.CONFLICT, `A project with that name already exists in ${conflictScope}.`);
|
|
47
|
+
}
|
|
48
|
+
// A slug clash is the one error worth another attempt; everything else
|
|
49
|
+
// leaves the loop for the route's error wrapper to map.
|
|
50
|
+
if (!isSlugConflict(err))
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
apiError(409, ApiErrorCode.CONFLICT, 'Could not pick a unique project slug after several attempts.');
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=create-project.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-project.js","sourceRoot":"","sources":["../../src/projects/create-project.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC3D,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE1D,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAE7B,4EAA4E;AAC5E,8EAA8E;AAC9E,8EAA8E;AAC9E,SAAS,cAAc,CAAC,GAAY;IACnC,OAAO,CACN,GAAG,YAAY,aAAa;QAC5B,GAAG,CAAC,UAAU,KAAK,GAAG;QACtB,0BAA0B,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAC5C,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,GAAY;IACnC,OAAO,CACN,GAAG,YAAY,aAAa;QAC5B,GAAG,CAAC,UAAU,KAAK,GAAG;QACtB,0BAA0B,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAC5C,CAAC;AACH,CAAC;AAiBD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAChD,KAEC,EACD,KAAmB,EACnB,EAAE,QAAQ,EAAE,YAAY,EAAE,aAAa,EAAwB;IAE/D,MAAM,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,YAAY,CAAC;IACrD,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAErC,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,GAAG,iBAAiB,EAAE,OAAO,EAAE,EAAE,CAAC;QAC9D,MAAM,OAAO,GAAY;YACxB,GAAG,KAAK;YACR,IAAI,EAAE,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,IAAI,OAAO,GAAG,CAAC,EAAE;YAC7D,SAAS,EAAE,GAAG;YACd,SAAS,EAAE,GAAG;YACd,SAAS,EAAE,IAAI;SACf,CAAC;QAEF,IAAI,CAAC;YACJ,MAAM,KAAK,CAAC,aAAa,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;YAC7C,OAAO,OAAO,CAAC;QAChB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,IAAI,cAAc,CAAC,GAAG,CAAC,EAAE,CAAC;gBACzB,QAAQ,CACP,GAAG,EACH,YAAY,CAAC,QAAQ,EACrB,8CAA8C,aAAa,GAAG,CAC9D,CAAC;YACH,CAAC;YACD,uEAAuE;YACvE,wDAAwD;YACxD,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC;gBAAE,MAAM,GAAG,CAAC;QACrC,CAAC;IACF,CAAC;IAED,QAAQ,CACP,GAAG,EACH,YAAY,CAAC,QAAQ,EACrB,8DAA8D,CAC9D,CAAC;AACH,CAAC"}
|