@voltro/protocol 0.1.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/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +347 -0
- package/dist/apikey.d.ts +88 -0
- package/dist/apikey.js +32 -0
- package/dist/auth-DCE6m7Bo.js +64 -0
- package/dist/index.d.ts +2564 -0
- package/dist/index.js +396 -0
- package/dist/jwt.d.ts +230 -0
- package/dist/jwt.js +142 -0
- package/dist/rest.d.ts +450 -0
- package/dist/rest.js +142 -0
- package/dist/serverErrorBus-C1KVL0dr.js +0 -0
- package/dist/session.d.ts +262 -0
- package/dist/session.js +106 -0
- package/package.json +67 -0
package/dist/rest.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { s as e } from "./auth-DCE6m7Bo.js";
|
|
2
|
+
import { a as t, i as n, o as r, r as i, t as a } from "./serverErrorBus-C1KVL0dr.js";
|
|
3
|
+
import { Effect as o, Schema as s } from "effect";
|
|
4
|
+
//#region src/publicApi.ts
|
|
5
|
+
var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.path ?? `/${t.version ?? "v1"}/${e.replace(/\./g, "/")}`, u = (e, t) => {
|
|
6
|
+
let n = e.publicApi;
|
|
7
|
+
if (n === void 0) throw Error(`publicApiRoute: descriptor '${e.name}' has no publicApi annotation`);
|
|
8
|
+
let r = c(e.kind, n), i = l(e.name, n), a = r === "GET" ? "query" : "body", o = s.Struct({ [a]: e.input }), u = (n.scopes ?? []).map((e) => f(e));
|
|
9
|
+
return p({
|
|
10
|
+
method: r,
|
|
11
|
+
path: i,
|
|
12
|
+
input: o,
|
|
13
|
+
output: e.output,
|
|
14
|
+
handler: (e, n) => t(e[a], n),
|
|
15
|
+
...u.length > 0 ? { guards: u } : {},
|
|
16
|
+
...n.summary === void 0 ? {} : { summary: n.summary },
|
|
17
|
+
...n.description === void 0 ? {} : { description: n.description },
|
|
18
|
+
...n.deprecated === void 0 ? {} : { deprecated: n.deprecated },
|
|
19
|
+
...n.sunset === void 0 ? {} : { sunset: n.sunset }
|
|
20
|
+
});
|
|
21
|
+
}, d = (e) => e.filter((e) => e.descriptor.publicApi !== void 0).map((e) => u(e.descriptor, e.invoke)).sort((e, t) => e.path.localeCompare(t.path)), f = (e) => (t) => {
|
|
22
|
+
let n = t.subject.scopes;
|
|
23
|
+
if (!(n && (n.includes(e) || n.includes("admin:full")))) return {
|
|
24
|
+
status: 403,
|
|
25
|
+
message: `Missing required scope: ${e}`
|
|
26
|
+
};
|
|
27
|
+
}, p = (e) => e, m = (e, t, n) => ({
|
|
28
|
+
status: e,
|
|
29
|
+
contentType: "application/json; charset=utf-8",
|
|
30
|
+
body: JSON.stringify(t),
|
|
31
|
+
...n ? { headers: n } : {}
|
|
32
|
+
}), h = (e) => {
|
|
33
|
+
if (e.length === 0) return;
|
|
34
|
+
let t = new TextDecoder().decode(e);
|
|
35
|
+
if (t.trim() !== "") return JSON.parse(t);
|
|
36
|
+
}, g = (e) => {
|
|
37
|
+
let t = {};
|
|
38
|
+
for (let [n, r] of new URLSearchParams(e)) t[n] = r;
|
|
39
|
+
return t;
|
|
40
|
+
}, _ = (e, t) => {
|
|
41
|
+
let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0), i = {};
|
|
42
|
+
for (let e = 0; e < n.length; e++) {
|
|
43
|
+
let t = n[e];
|
|
44
|
+
if (t !== void 0 && t.startsWith(":")) {
|
|
45
|
+
let n = r[e];
|
|
46
|
+
if (n !== void 0) try {
|
|
47
|
+
i[t.slice(1)] = decodeURIComponent(n);
|
|
48
|
+
} catch {
|
|
49
|
+
i[t.slice(1)] = n;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return i;
|
|
54
|
+
}, v = (e, t) => ({
|
|
55
|
+
query: g(e.query),
|
|
56
|
+
params: t,
|
|
57
|
+
body: h(e.rawBody)
|
|
58
|
+
}), y = /* @__PURE__ */ new Set([
|
|
59
|
+
"POST",
|
|
60
|
+
"PUT",
|
|
61
|
+
"PATCH",
|
|
62
|
+
"DELETE"
|
|
63
|
+
]), b = (c, l) => {
|
|
64
|
+
let u = c.input ? s.decodeUnknown(c.input) : void 0, d = s.encode(c.output), f = {};
|
|
65
|
+
return c.deprecated !== void 0 && (f.Deprecation = "true"), c.sunset !== void 0 && (f.Sunset = c.sunset), {
|
|
66
|
+
method: "*",
|
|
67
|
+
path: c.path,
|
|
68
|
+
handle: async (s) => {
|
|
69
|
+
if (s.method.toUpperCase() !== c.method) return m(405, {
|
|
70
|
+
error: "Method Not Allowed",
|
|
71
|
+
allow: c.method
|
|
72
|
+
}, {
|
|
73
|
+
...f,
|
|
74
|
+
Allow: c.method
|
|
75
|
+
});
|
|
76
|
+
if (c.sunset !== void 0) {
|
|
77
|
+
let e = Date.parse(c.sunset);
|
|
78
|
+
if (!Number.isNaN(e) && Date.now() >= e) return m(410, {
|
|
79
|
+
error: "Gone",
|
|
80
|
+
...c.deprecated === void 0 ? {} : { replacement: c.deprecated }
|
|
81
|
+
}, f);
|
|
82
|
+
}
|
|
83
|
+
let p;
|
|
84
|
+
if (u) {
|
|
85
|
+
let e = v(s, _(c.path, s.path)), t = await o.runPromise(u(e).pipe(o.map((e) => ({
|
|
86
|
+
ok: !0,
|
|
87
|
+
value: e
|
|
88
|
+
})), o.catchAll((e) => o.succeed({
|
|
89
|
+
ok: !1,
|
|
90
|
+
cause: e
|
|
91
|
+
}))));
|
|
92
|
+
if (!t.ok) return m(400, {
|
|
93
|
+
error: "Invalid request",
|
|
94
|
+
detail: String(t.cause)
|
|
95
|
+
}, f);
|
|
96
|
+
p = t.value;
|
|
97
|
+
}
|
|
98
|
+
let h = l.resolveSubject ? await l.resolveSubject(s.headers) : e(s.headers["x-tenant"] ?? null), g = {
|
|
99
|
+
subject: h,
|
|
100
|
+
headers: s.headers,
|
|
101
|
+
store: l.store
|
|
102
|
+
};
|
|
103
|
+
if (c.guards) for (let e of c.guards) {
|
|
104
|
+
let t = await e(g);
|
|
105
|
+
if (t) return m(t.status, { error: t.message }, f);
|
|
106
|
+
}
|
|
107
|
+
let b = l.idempotency, x = b && y.has(c.method) ? s.headers[b.header.toLowerCase()] : void 0, S = b && x ? r(h.tenantId, c.method, c.path) : "";
|
|
108
|
+
if (b && x) {
|
|
109
|
+
let e = await i(b.store, S, x, b.ttlMs, Date.now());
|
|
110
|
+
if (e.kind === "replay") return m(e.response.status, e.response.body, {
|
|
111
|
+
...f,
|
|
112
|
+
"Idempotency-Replayed": "true"
|
|
113
|
+
});
|
|
114
|
+
if (e.kind === "conflict") return m(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
let e = await c.handler(p, g), n = await o.runPromise(d(e));
|
|
118
|
+
return b && x && await t(b.store, S, x, {
|
|
119
|
+
status: 200,
|
|
120
|
+
body: n
|
|
121
|
+
}, Date.now()), m(200, n, f);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (b && x && await n(b.store, S, x), typeof e == "object" && e && typeof e.status == "number") {
|
|
124
|
+
let t = e;
|
|
125
|
+
return m(t.status, { error: t.message ?? "Error" }, f);
|
|
126
|
+
}
|
|
127
|
+
return a({
|
|
128
|
+
error: e,
|
|
129
|
+
source: "rest",
|
|
130
|
+
name: `${c.method} ${c.path}`,
|
|
131
|
+
fields: { status: 500 }
|
|
132
|
+
}), m(500, { error: "Internal Server Error" }, f);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}, x = (e, t = {}) => e.map((e) => b(e, t)), S = async (e, t) => {
|
|
137
|
+
let n = await e[0].handle(t);
|
|
138
|
+
for (let r = 1; r < e.length && n.status === 405; r++) n = await e[r].handle(t);
|
|
139
|
+
return n;
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
export { d as collectPublicApiRoutes, p as defineRestRoute, c as derivePublicMethod, l as derivePublicPath, S as dispatchSharedPath, _ as matchPathParams, u as publicApiRoute, f as requireScope, x as restRoutesToHttpRoutes };
|
|
Binary file
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { Schema } from 'effect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fail-closed check that a real session secret is configured. Throws a plain
|
|
5
|
+
* `Error` (a fatal boot condition, not a wire error) when `NODE_ENV` is
|
|
6
|
+
* `'production'` and `VOLTRO_SESSION_SECRET` is missing, the dev fallback, or
|
|
7
|
+
* too short. No-op otherwise. Idempotent — safe to call from every prod entry.
|
|
8
|
+
*/
|
|
9
|
+
export declare const assertProductionSessionSecret: (env?: NodeJS.ProcessEnv) => void;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a `Set-Cookie` header value. Pure helper — the caller adds it
|
|
13
|
+
* to the actual HTTP response. Used by the framework's login route
|
|
14
|
+
* pattern + by user code that wants to set its own cookies during a
|
|
15
|
+
* mutation/action.
|
|
16
|
+
*/
|
|
17
|
+
export declare const buildSetCookie: (name: string, value: string, options?: SetCookieOptions) => string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check an `Authorization: Bearer <token>` header against an expected token.
|
|
21
|
+
*
|
|
22
|
+
* - `expected` unset/empty → `true` (no token configured = open; the caller
|
|
23
|
+
* decided not to gate this surface).
|
|
24
|
+
* - header absent, or not using the `Bearer ` scheme → `false`.
|
|
25
|
+
* - presented token compared to `expected` in CONSTANT TIME.
|
|
26
|
+
*
|
|
27
|
+
* `headers` keys may be any casing (`authorization` / `Authorization`).
|
|
28
|
+
*/
|
|
29
|
+
export declare const checkBearer: (headers: Readonly<Record<string, string | undefined>>, expected: string | undefined) => boolean;
|
|
30
|
+
|
|
31
|
+
/** Key id assigned to `VOLTRO_SESSION_SECRET_PREVIOUS` when no explicit
|
|
32
|
+
* `VOLTRO_SESSION_KID_PREVIOUS` is set. The kid is a non-secret label —
|
|
33
|
+
* rotation works with just the `_PREVIOUS` secret var. */
|
|
34
|
+
export declare const DEFAULT_PREVIOUS_SESSION_KID: "k-previous";
|
|
35
|
+
|
|
36
|
+
/** Key id stamped for bare-string secrets / when `VOLTRO_SESSION_KID` is
|
|
37
|
+
* unset. Exported so callers building a `SessionSecrets` from a plain
|
|
38
|
+
* string produce the same label the resolver would. */
|
|
39
|
+
export declare const DEFAULT_SESSION_KID: "k0";
|
|
40
|
+
|
|
41
|
+
/** A keyed signing secret. `kid` is stamped into the payload on sign and
|
|
42
|
+
* matched on verify; it's an opaque, non-secret label (e.g. `'2026-06'`). */
|
|
43
|
+
export declare interface KeyedSecret {
|
|
44
|
+
readonly kid: string;
|
|
45
|
+
readonly secret: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimum length we accept for a production session secret. 32 bytes of
|
|
49
|
+
* entropy is the floor for HMAC-SHA256; shorter values are almost always
|
|
50
|
+
* hand-typed placeholders, not real secrets. */
|
|
51
|
+
export declare const MIN_SESSION_SECRET_LENGTH: 32;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read a cookie value by name from a `Cookie:` header string.
|
|
55
|
+
* Returns undefined if the cookie isn't present.
|
|
56
|
+
*/
|
|
57
|
+
export declare const readCookie: (header: string | undefined, name: string) => string | undefined;
|
|
58
|
+
|
|
59
|
+
/** Resolve the HMAC session secret with the dev fallback applied. */
|
|
60
|
+
export declare const resolveSessionSecret: () => string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the keyed session-secret set for multi-key rotation.
|
|
64
|
+
*
|
|
65
|
+
* - `current` is built from `VOLTRO_SESSION_SECRET` (dev fallback
|
|
66
|
+
* applied), keyed `VOLTRO_SESSION_KID` (defaults to `'k0'`).
|
|
67
|
+
* - `previous` is built from `VOLTRO_SESSION_SECRET_PREVIOUS`, keyed
|
|
68
|
+
* `VOLTRO_SESSION_KID_PREVIOUS` (defaults to `'k-previous'` — the kid
|
|
69
|
+
* is a non-secret label, so rotation needs only the secret var).
|
|
70
|
+
* During a rotation window you set the new secret as current and move
|
|
71
|
+
* the old one into `VOLTRO_SESSION_SECRET_PREVIOUS`; sessions signed
|
|
72
|
+
* with the old key keep verifying until they expire, then you drop
|
|
73
|
+
* the `_PREVIOUS` var.
|
|
74
|
+
*
|
|
75
|
+
* Sign always uses `current`. Verify tries `current` first, then
|
|
76
|
+
* `previous` if present.
|
|
77
|
+
*/
|
|
78
|
+
export declare const resolveSessionSecrets: () => SessionSecrets;
|
|
79
|
+
|
|
80
|
+
export declare type SessionPayload = typeof SessionPayload_2.Type;
|
|
81
|
+
|
|
82
|
+
declare const SessionPayload_2: Schema.Struct<{
|
|
83
|
+
subject: Schema.Union<[Schema.Struct<{
|
|
84
|
+
type: Schema.Literal<["user"]>;
|
|
85
|
+
id: typeof Schema.String;
|
|
86
|
+
tenantId: typeof Schema.String;
|
|
87
|
+
scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
|
|
88
|
+
metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
89
|
+
}>, Schema.Struct<{
|
|
90
|
+
type: Schema.Literal<["apiKey"]>;
|
|
91
|
+
id: typeof Schema.String;
|
|
92
|
+
tenantId: typeof Schema.String;
|
|
93
|
+
scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
|
|
94
|
+
metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
95
|
+
}>, Schema.Struct<{
|
|
96
|
+
type: Schema.Literal<["serviceAccount"]>;
|
|
97
|
+
id: typeof Schema.String;
|
|
98
|
+
tenantId: typeof Schema.String;
|
|
99
|
+
scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
|
|
100
|
+
metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
101
|
+
}>, Schema.Struct<{
|
|
102
|
+
type: Schema.Literal<["anonymous"]>;
|
|
103
|
+
id: typeof Schema.Null;
|
|
104
|
+
tenantId: Schema.NullOr<typeof Schema.String>;
|
|
105
|
+
}>, Schema.Struct<{
|
|
106
|
+
type: Schema.Literal<["system"]>;
|
|
107
|
+
id: typeof Schema.String;
|
|
108
|
+
tenantId: typeof Schema.Null;
|
|
109
|
+
scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
|
|
110
|
+
metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
111
|
+
}>]>;
|
|
112
|
+
/** Unix seconds when this session expires. Verify rejects past expiry. */
|
|
113
|
+
exp: typeof Schema.Number;
|
|
114
|
+
/** Unix seconds when this session was issued. Drives sliding-window
|
|
115
|
+
* auto-renewal: verify signals a renew once `now - iat` passes the
|
|
116
|
+
* renewal threshold (default 70% of the session's lifetime). */
|
|
117
|
+
iat: typeof Schema.Number;
|
|
118
|
+
/** Key id of the signing secret. Stamped from the current key on sign;
|
|
119
|
+
* read on verify to pick which secret to check first. Absent on
|
|
120
|
+
* values minted with a bare-string secret (single-key path). */
|
|
121
|
+
kid: Schema.optional<typeof Schema.String>;
|
|
122
|
+
}>;
|
|
123
|
+
|
|
124
|
+
declare type SessionPayload = typeof SessionPayload_2.Type;
|
|
125
|
+
|
|
126
|
+
/** The resolved secret set: `current` always signs; `previous` is accepted
|
|
127
|
+
* on verify during a rotation window. */
|
|
128
|
+
export declare interface SessionSecrets {
|
|
129
|
+
readonly current: KeyedSecret;
|
|
130
|
+
readonly previous?: KeyedSecret;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export declare interface SetCookieOptions {
|
|
134
|
+
/** Cookie path. Default `/`. */
|
|
135
|
+
readonly path?: string;
|
|
136
|
+
/** Max-Age in seconds. Omit for a session cookie that clears on
|
|
137
|
+
* browser close. Set 0 (or a past date) to delete. */
|
|
138
|
+
readonly maxAgeSeconds?: number;
|
|
139
|
+
/** Defaults to true. Block JS access to the cookie. */
|
|
140
|
+
readonly httpOnly?: boolean;
|
|
141
|
+
/** Defaults to 'lax'. Use 'strict' for higher-security flows. */
|
|
142
|
+
readonly sameSite?: 'strict' | 'lax' | 'none';
|
|
143
|
+
/** Defaults to true. Required when sameSite='none'. */
|
|
144
|
+
readonly secure?: boolean;
|
|
145
|
+
/** Cookie domain. Omit to scope to the current host. */
|
|
146
|
+
readonly domain?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export declare interface SignOptions {
|
|
150
|
+
/** TTL in seconds. Defaults to 7 days. */
|
|
151
|
+
readonly ttlSeconds?: number;
|
|
152
|
+
/** Clock override (epoch milliseconds). Defaults to `Date.now`. A
|
|
153
|
+
* testing seam — lets suites mint back-dated sessions to exercise the
|
|
154
|
+
* sliding-window renewal without wall-clock sleeps. */
|
|
155
|
+
readonly now?: () => number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Mint a signed session cookie value for the given subject. Returns the
|
|
160
|
+
* value you'd put in `Set-Cookie: <name>=<value>; HttpOnly; SameSite=Lax;
|
|
161
|
+
* Secure; Path=/; Max-Age=<ttl>`.
|
|
162
|
+
*
|
|
163
|
+
* `secret` accepts either a bare string (single-key path — the `kid` is
|
|
164
|
+
* omitted from the payload) or a `KeyedSecret` (multi-key rotation — the
|
|
165
|
+
* `kid` is stamped so verify can pick the matching key first).
|
|
166
|
+
*
|
|
167
|
+
* The cookie value itself is opaque — clients can read it but can't
|
|
168
|
+
* forge a new one without the secret. `HttpOnly` blocks JS access for
|
|
169
|
+
* defence-in-depth; the verify side doesn't care whether the client
|
|
170
|
+
* could read it.
|
|
171
|
+
*/
|
|
172
|
+
export declare const signSession: (subject: Subject, secret: string | KeyedSecret, options?: SignOptions) => string;
|
|
173
|
+
|
|
174
|
+
declare const Subject: Schema.Union<[Schema.Struct<{
|
|
175
|
+
type: Schema.Literal<["user"]>;
|
|
176
|
+
id: typeof Schema.String;
|
|
177
|
+
tenantId: typeof Schema.String;
|
|
178
|
+
scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
|
|
179
|
+
metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
180
|
+
}>, Schema.Struct<{
|
|
181
|
+
type: Schema.Literal<["apiKey"]>;
|
|
182
|
+
id: typeof Schema.String;
|
|
183
|
+
tenantId: typeof Schema.String;
|
|
184
|
+
scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
|
|
185
|
+
metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
186
|
+
}>, Schema.Struct<{
|
|
187
|
+
type: Schema.Literal<["serviceAccount"]>;
|
|
188
|
+
id: typeof Schema.String;
|
|
189
|
+
tenantId: typeof Schema.String;
|
|
190
|
+
scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
|
|
191
|
+
metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
192
|
+
}>, Schema.Struct<{
|
|
193
|
+
type: Schema.Literal<["anonymous"]>;
|
|
194
|
+
id: typeof Schema.Null;
|
|
195
|
+
tenantId: Schema.NullOr<typeof Schema.String>;
|
|
196
|
+
}>, Schema.Struct<{
|
|
197
|
+
type: Schema.Literal<["system"]>;
|
|
198
|
+
id: typeof Schema.String;
|
|
199
|
+
tenantId: typeof Schema.Null;
|
|
200
|
+
scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
|
|
201
|
+
metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
|
|
202
|
+
}>]>;
|
|
203
|
+
|
|
204
|
+
declare type Subject = typeof Subject.Type;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Constant-time string equality via `timingSafeEqual`. Returns false fast
|
|
208
|
+
* on a length mismatch (the lengths aren't secret); only equal-length inputs
|
|
209
|
+
* reach the timing-safe compare, so an attacker can't recover the secret a
|
|
210
|
+
* byte at a time from the response time.
|
|
211
|
+
*/
|
|
212
|
+
export declare const timingSafeStringEqual: (a: string, b: string) => boolean;
|
|
213
|
+
|
|
214
|
+
export declare interface VerifyOptions {
|
|
215
|
+
/** Fraction of [iat, exp] after which `renew` flips true. Default 0.7. */
|
|
216
|
+
readonly renewFraction?: number;
|
|
217
|
+
/** Clock override (epoch milliseconds). Defaults to `Date.now`. */
|
|
218
|
+
readonly now?: () => number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Outcome of a keyed verify. `renew` is true once the session has crossed
|
|
222
|
+
* the sliding-window threshold — the caller re-issues the cookie so an
|
|
223
|
+
* active user never gets logged out mid-session. */
|
|
224
|
+
export declare interface VerifyResult {
|
|
225
|
+
readonly subject: Subject;
|
|
226
|
+
readonly renew: boolean;
|
|
227
|
+
/** The key id that verified the value (current or previous). */
|
|
228
|
+
readonly kid: string;
|
|
229
|
+
/** Unix seconds the verified session expires. */
|
|
230
|
+
readonly exp: number;
|
|
231
|
+
/** Unix seconds the verified session was issued. `exp - iat` is the
|
|
232
|
+
* session's original lifetime — a renewing caller re-issues with the
|
|
233
|
+
* same lifetime rather than resetting to the default TTL. */
|
|
234
|
+
readonly iat: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Verify a session cookie value. Returns the decoded Subject on success,
|
|
239
|
+
* null on any failure (malformed, signature mismatch, expired). Never
|
|
240
|
+
* throws — callers shouldn't have to wrap this in try/catch.
|
|
241
|
+
*
|
|
242
|
+
* Uses `timingSafeEqual` for the signature comparison so attackers
|
|
243
|
+
* can't recover bytes one-at-a-time via response-time analysis.
|
|
244
|
+
*
|
|
245
|
+
* This is the single-secret entry point and stays a pure
|
|
246
|
+
* `Subject | null`. Use `verifySessionKeyed` for the multi-key +
|
|
247
|
+
* sliding-window result shape.
|
|
248
|
+
*/
|
|
249
|
+
export declare const verifySession: (cookieValue: string, secret: string) => Subject | null;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Multi-key + sliding-window verify. Tries `secrets.current` first, then
|
|
253
|
+
* `secrets.previous` (if present). On success returns the Subject, the
|
|
254
|
+
* `kid` that verified, and a `renew` flag the caller acts on by
|
|
255
|
+
* re-issuing the cookie (so the session slides forward while in use).
|
|
256
|
+
* Returns null on any failure.
|
|
257
|
+
*/
|
|
258
|
+
export declare const verifySessionKeyed: (cookieValue: string, secrets: SessionSecrets, options?: VerifyOptions) => VerifyResult | null;
|
|
259
|
+
|
|
260
|
+
export declare const VOLTRO_DEV_SESSION_SECRET: "voltro-dev-session-secret-32b-DO-NOT-USE-IN-PROD";
|
|
261
|
+
|
|
262
|
+
export { }
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { i as e } from "./auth-DCE6m7Bo.js";
|
|
2
|
+
import { Schema as t } from "effect";
|
|
3
|
+
import { createHmac as n, timingSafeEqual as r } from "node:crypto";
|
|
4
|
+
//#region src/session.ts
|
|
5
|
+
var i = t.Struct({
|
|
6
|
+
subject: e,
|
|
7
|
+
exp: t.Number,
|
|
8
|
+
iat: t.Number,
|
|
9
|
+
kid: t.optional(t.String)
|
|
10
|
+
}), a = "k0", o = "k-previous", s = "voltro-dev-session-secret-32b-DO-NOT-USE-IN-PROD", c = () => process.env.VOLTRO_SESSION_SECRET ?? "voltro-dev-session-secret-32b-DO-NOT-USE-IN-PROD", l = () => {
|
|
11
|
+
let e = {
|
|
12
|
+
kid: process.env.VOLTRO_SESSION_KID ?? "k0",
|
|
13
|
+
secret: process.env.VOLTRO_SESSION_SECRET ?? "voltro-dev-session-secret-32b-DO-NOT-USE-IN-PROD"
|
|
14
|
+
}, t = process.env.VOLTRO_SESSION_SECRET_PREVIOUS;
|
|
15
|
+
return t ? {
|
|
16
|
+
current: e,
|
|
17
|
+
previous: {
|
|
18
|
+
kid: process.env.VOLTRO_SESSION_KID_PREVIOUS ?? "k-previous",
|
|
19
|
+
secret: t
|
|
20
|
+
}
|
|
21
|
+
} : { current: e };
|
|
22
|
+
}, u = 32, d = (e = process.env) => {
|
|
23
|
+
if (e.NODE_ENV !== "production") return;
|
|
24
|
+
let t = e.VOLTRO_SESSION_SECRET;
|
|
25
|
+
if (t === void 0 || t === "") throw Error("VOLTRO_SESSION_SECRET is not set. In production the framework refuses to boot rather than sign session cookies with the PUBLIC dev fallback secret (which would let anyone forge any user's session). Set VOLTRO_SESSION_SECRET to a high-entropy value (>= 32 chars). Generate one with `voltro secret generate session`.");
|
|
26
|
+
if (t === "voltro-dev-session-secret-32b-DO-NOT-USE-IN-PROD") throw Error("VOLTRO_SESSION_SECRET is set to the built-in dev fallback secret, which is PUBLIC in the framework source. Set a real, high-entropy secret in production. Generate one with `voltro secret generate session`.");
|
|
27
|
+
if (t.length < 32) throw Error(`VOLTRO_SESSION_SECRET is only ${t.length} chars — too short for a production session secret (need >= 32). This looks like a placeholder. Generate a real one with \`voltro secret generate session\`.`);
|
|
28
|
+
}, f = t.encodeSync(i), p = t.decodeUnknownSync(i), m = (e) => (typeof e == "string" ? Buffer.from(e, "utf8") : e).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), h = (e) => {
|
|
29
|
+
let t = e.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - e.length % 4) % 4);
|
|
30
|
+
return Buffer.from(t, "base64");
|
|
31
|
+
}, g = 3600 * 24 * 7, _ = .7, v = (e, t) => m(n("sha256", t).update(e).digest()), y = (e) => typeof e == "string" ? {
|
|
32
|
+
kid: "k0",
|
|
33
|
+
secret: e
|
|
34
|
+
} : e, b = (e, t, n = {}) => {
|
|
35
|
+
let r = y(t);
|
|
36
|
+
if (!r.secret) throw Error("signSession: secret must be a non-empty string");
|
|
37
|
+
let i = n.ttlSeconds ?? g, a = Math.floor((n.now ?? Date.now)() / 1e3), o = {
|
|
38
|
+
subject: e,
|
|
39
|
+
exp: a + i,
|
|
40
|
+
iat: a,
|
|
41
|
+
...typeof t == "string" ? {} : { kid: r.kid }
|
|
42
|
+
}, s = m(JSON.stringify(f(o)));
|
|
43
|
+
return `${s}.${v(s, r.secret)}`;
|
|
44
|
+
}, x = (e, t, i, a) => {
|
|
45
|
+
let o = n("sha256", i).update(e).digest(), s = h(t);
|
|
46
|
+
if (s.length !== o.length || !r(s, o)) return null;
|
|
47
|
+
try {
|
|
48
|
+
let t = h(e).toString("utf8"), n = p(JSON.parse(t));
|
|
49
|
+
return n.exp < Math.floor(a() / 1e3) ? null : n;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}, S = (e, t) => {
|
|
54
|
+
if (!e || !t) return null;
|
|
55
|
+
let n = e.indexOf(".");
|
|
56
|
+
if (n <= 0 || n === e.length - 1) return null;
|
|
57
|
+
let r = x(e.slice(0, n), e.slice(n + 1), t, Date.now);
|
|
58
|
+
return r ? r.subject : null;
|
|
59
|
+
}, C = (e, t, n = {}) => {
|
|
60
|
+
if (!e) return null;
|
|
61
|
+
let r = e.indexOf(".");
|
|
62
|
+
if (r <= 0 || r === e.length - 1) return null;
|
|
63
|
+
let i = e.slice(0, r), a = e.slice(r + 1), o = t.previous ? [t.current, t.previous] : [t.current], s = n.now ?? Date.now;
|
|
64
|
+
for (let e of o) {
|
|
65
|
+
if (!e.secret) continue;
|
|
66
|
+
let t = x(i, a, e.secret, s);
|
|
67
|
+
if (!t) continue;
|
|
68
|
+
let r = n.renewFraction ?? _, o = Math.floor(s() / 1e3), c = t.exp - t.iat, l = t.iat + c * r;
|
|
69
|
+
return {
|
|
70
|
+
subject: t.subject,
|
|
71
|
+
renew: o >= l,
|
|
72
|
+
kid: e.kid,
|
|
73
|
+
exp: t.exp,
|
|
74
|
+
iat: t.iat
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}, w = (e, t) => {
|
|
79
|
+
let n = Buffer.from(e, "utf8"), i = Buffer.from(t, "utf8");
|
|
80
|
+
return n.length === i.length && r(n, i);
|
|
81
|
+
}, T = (e, t) => {
|
|
82
|
+
if (!t || t.length === 0) return !0;
|
|
83
|
+
let n = e.authorization ?? e.Authorization;
|
|
84
|
+
return !n || !n.startsWith("Bearer ") ? !1 : w(n.slice(7).trim(), t);
|
|
85
|
+
}, E = (e, t) => {
|
|
86
|
+
if (e) for (let n of e.split(";")) {
|
|
87
|
+
let e = n.indexOf("=");
|
|
88
|
+
if (e < 0 || n.slice(0, e).trim() !== t) continue;
|
|
89
|
+
let r = n.slice(e + 1).trim();
|
|
90
|
+
r.startsWith("\"") && r.endsWith("\"") && (r = r.slice(1, -1));
|
|
91
|
+
try {
|
|
92
|
+
return decodeURIComponent(r);
|
|
93
|
+
} catch {
|
|
94
|
+
return r;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}, D = (e, t, n = {}) => {
|
|
98
|
+
let r = [`${e}=${encodeURIComponent(t)}`];
|
|
99
|
+
if (r.push(`Path=${n.path ?? "/"}`), n.maxAgeSeconds !== void 0 && r.push(`Max-Age=${n.maxAgeSeconds}`), r.push("HttpOnly"), n.httpOnly === !1) {
|
|
100
|
+
let e = r.indexOf("HttpOnly");
|
|
101
|
+
e >= 0 && r.splice(e, 1);
|
|
102
|
+
}
|
|
103
|
+
return r.push(`SameSite=${n.sameSite ?? "Lax"}`), (n.secure ?? !0) && r.push("Secure"), n.domain && r.push(`Domain=${n.domain}`), r.join("; ");
|
|
104
|
+
};
|
|
105
|
+
//#endregion
|
|
106
|
+
export { o as DEFAULT_PREVIOUS_SESSION_KID, a as DEFAULT_SESSION_KID, u as MIN_SESSION_SECRET_LENGTH, s as VOLTRO_DEV_SESSION_SECRET, d as assertProductionSessionSecret, D as buildSetCookie, T as checkBearer, E as readCookie, c as resolveSessionSecret, l as resolveSessionSecrets, b as signSession, w as timingSafeStringEqual, S as verifySession, C as verifySessionKeyed };
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voltro/protocol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Voltro wire + plugin contract — defineQuery/Mutation/Action/Stream, definePlugin, sessions / JWT / API-keys, and the RPC protocol.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"voltro",
|
|
7
|
+
"typescript",
|
|
8
|
+
"framework"
|
|
9
|
+
],
|
|
10
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
11
|
+
"homepage": "https://voltro.dev",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"email": "support@voltro.dev"
|
|
14
|
+
},
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Voltro UG",
|
|
17
|
+
"url": "https://voltro.dev"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./rest": {
|
|
27
|
+
"types": "./dist/rest.d.ts",
|
|
28
|
+
"import": "./dist/rest.js",
|
|
29
|
+
"default": "./dist/rest.js"
|
|
30
|
+
},
|
|
31
|
+
"./session": {
|
|
32
|
+
"types": "./dist/session.d.ts",
|
|
33
|
+
"import": "./dist/session.js",
|
|
34
|
+
"default": "./dist/session.js"
|
|
35
|
+
},
|
|
36
|
+
"./jwt": {
|
|
37
|
+
"types": "./dist/jwt.d.ts",
|
|
38
|
+
"import": "./dist/jwt.js",
|
|
39
|
+
"default": "./dist/jwt.js"
|
|
40
|
+
},
|
|
41
|
+
"./apikey": {
|
|
42
|
+
"types": "./dist/apikey.d.ts",
|
|
43
|
+
"import": "./dist/apikey.js",
|
|
44
|
+
"default": "./dist/apikey.js"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"main": "./dist/index.js",
|
|
48
|
+
"module": "./dist/index.js",
|
|
49
|
+
"types": "./dist/index.d.ts",
|
|
50
|
+
"sideEffects": false,
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=24.0.0"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@effect/sql": "^0.51.1",
|
|
56
|
+
"@voltro/database": "0.1.0",
|
|
57
|
+
"jose": "^6.2.3"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@effect/platform": "^0.96.2",
|
|
61
|
+
"@effect/rpc": "^0.75.1",
|
|
62
|
+
"effect": "^3.21.4"
|
|
63
|
+
},
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public"
|
|
66
|
+
}
|
|
67
|
+
}
|