@better-forge/contracts 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BetterStatusPage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @better-forge/contracts
2
+
3
+ Browser-safe types, core translations and a storage wrapper that never throws shared by the other modules.
4
+
5
+ Status: skeleton. Depends on: nothing.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What an API token may do. A token carries a list of permissions instead of a role: each one names a part of the
3
+ * admin API and whether it may be read (`:read`) or changed (`:write`, which includes reading). `vault:use` is a
4
+ * separate permission to use secrets from a vault in a configuration.
5
+ */
6
+ export interface TokenResource {
7
+ key: string;
8
+ label: string;
9
+ description: string;
10
+ /** `read` only for parts that cannot be changed through the API. */
11
+ levels: readonly ('read' | 'write')[];
12
+ }
13
+ /** Lets a token set, change or test a configuration that reads a secret from a vault. */
14
+ export declare const VAULT_USE_SCOPE = "vault:use";
15
+ export interface TokenPreset {
16
+ label: string;
17
+ description: string;
18
+ scopes: readonly string[];
19
+ }
20
+ /**
21
+ * The permissions a token needs for a request: `<resource>:read` for GET and HEAD, `<resource>:write` for anything
22
+ * else. The vault catalogue is the one part with a single permission.
23
+ */
24
+ export declare function requiredScope(resource: string, method: string): string;
25
+ /** Whether a token with `granted` may do what needs `required`. Writing a part includes reading it. */
26
+ export declare function tokenAllows(granted: readonly string[], required: string): boolean;
27
+ /**
28
+ * A clean list of permissions out of `known` (reading added wherever writing is granted, ordered as `known` is), or
29
+ * null if one is unknown or none is given.
30
+ */
31
+ export declare function normalizeScopes(input: unknown, known: readonly string[]): string[] | null;
32
+ //# sourceMappingURL=apiToken.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiToken.d.ts","sourceRoot":"","sources":["../src/apiToken.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,oEAAoE;IACpE,MAAM,EAAE,SAAS,CAAC,MAAM,GAAG,OAAO,CAAC,EAAE,CAAA;CACtC;AAED,yFAAyF;AACzF,eAAO,MAAM,eAAe,cAAc,CAAA;AAE1C,MAAM,WAAW,WAAW;IAAG,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE;AAE9F;;;GAGG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAGtE;AAED,uGAAuG;AACvG,wBAAgB,WAAW,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGjF;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,GAAG,IAAI,CAKzF"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What an API token may do. A token carries a list of permissions instead of a role: each one names a part of the
3
+ * admin API and whether it may be read (`:read`) or changed (`:write`, which includes reading). `vault:use` is a
4
+ * separate permission to use secrets from a vault in a configuration.
5
+ */
6
+ /** Lets a token set, change or test a configuration that reads a secret from a vault. */
7
+ export const VAULT_USE_SCOPE = 'vault:use';
8
+ /**
9
+ * The permissions a token needs for a request: `<resource>:read` for GET and HEAD, `<resource>:write` for anything
10
+ * else. The vault catalogue is the one part with a single permission.
11
+ */
12
+ export function requiredScope(resource, method) {
13
+ if (resource === 'vault')
14
+ return VAULT_USE_SCOPE;
15
+ return `${resource}:${method === 'GET' || method === 'HEAD' ? 'read' : 'write'}`;
16
+ }
17
+ /** Whether a token with `granted` may do what needs `required`. Writing a part includes reading it. */
18
+ export function tokenAllows(granted, required) {
19
+ if (granted.includes(required))
20
+ return true;
21
+ return required.endsWith(':read') && granted.includes(`${required.slice(0, -':read'.length)}:write`);
22
+ }
23
+ /**
24
+ * A clean list of permissions out of `known` (reading added wherever writing is granted, ordered as `known` is), or
25
+ * null if one is unknown or none is given.
26
+ */
27
+ export function normalizeScopes(input, known) {
28
+ if (!Array.isArray(input) || input.length === 0 || !input.every((scope) => typeof scope === 'string' && known.includes(scope)))
29
+ return null;
30
+ const scopes = new Set(input);
31
+ for (const scope of input)
32
+ if (scope.endsWith(':write') && known.includes(`${scope.slice(0, -':write'.length)}:read`))
33
+ scopes.add(`${scope.slice(0, -':write'.length)}:read`);
34
+ return known.filter((scope) => scopes.has(scope));
35
+ }
36
+ //# sourceMappingURL=apiToken.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiToken.js","sourceRoot":"","sources":["../src/apiToken.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAUH,yFAAyF;AACzF,MAAM,CAAC,MAAM,eAAe,GAAG,WAAW,CAAA;AAI1C;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,QAAgB,EAAE,MAAc;IAC5D,IAAI,QAAQ,KAAK,OAAO;QAAE,OAAO,eAAe,CAAA;IAChD,OAAO,GAAG,QAAQ,IAAI,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,CAAA;AAClF,CAAC;AAED,uGAAuG;AACvG,MAAM,UAAU,WAAW,CAAC,OAA0B,EAAE,QAAgB;IACtE,IAAI,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAA;IAC3C,OAAO,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;AACtG,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,KAAc,EAAE,KAAwB;IACtE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAA;IAC3I,MAAM,MAAM,GAAG,IAAI,GAAG,CAAS,KAAiB,CAAC,CAAA;IACjD,KAAK,MAAM,KAAK,IAAI,KAAiB;QAAE,IAAI,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACzL,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAA;AACnD,CAAC"}
@@ -0,0 +1,22 @@
1
+ /** `allow` records a successful sign-in; `deny` a refused action, such as a sign-in with a wrong password. */
2
+ export type AuditAction = 'create' | 'update' | 'delete' | 'allow' | 'deny';
3
+ /** The kinds of object the shared modules write to the audit log; an app adds its own. */
4
+ export type KitAuditEntityType = 'notification_channel' | 'smtp_settings' | 'vault' | 'vault_secret' | 'user' | 'api_token' | 'oidc_settings'
5
+ /** Security changes on a user account: password, two-factor, SSO link. */
6
+ | 'user-security'
7
+ /** A configuration import; the entities it changed have entries of their own. */
8
+ | 'config' | 'backup' | 'backup-config' | 'notification_delivery'
9
+ /** Every sign-in attempt, with a password or SSO; `diff.method` says which. */
10
+ | 'sign_in';
11
+ export interface AuditLogEntry<EntityType extends string = string> {
12
+ id: number;
13
+ userId: number;
14
+ userEmail: string;
15
+ action: AuditAction;
16
+ entityType: EntityType;
17
+ entityId: string | null;
18
+ entityName: string;
19
+ diff: Record<string, unknown> | null;
20
+ timestamp: number;
21
+ }
22
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAAA,8GAA8G;AAC9G,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAA;AAE3E,0FAA0F;AAC1F,MAAM,MAAM,kBAAkB,GAC1B,sBAAsB,GACtB,eAAe,GACf,OAAO,GACP,cAAc,GACd,MAAM,GACN,WAAW,GACX,eAAe;AACjB,0EAA0E;GACxE,eAAe;AACjB,iFAAiF;GAC/E,QAAQ,GACR,QAAQ,GACR,eAAe,GACf,uBAAuB;AACzB,+EAA+E;GAC7E,SAAS,CAAA;AAEb,MAAM,WAAW,aAAa,CAAC,UAAU,SAAS,MAAM,GAAG,MAAM;IAC/D,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,WAAW,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;IACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IACpC,SAAS,EAAE,MAAM,CAAA;CAClB"}
package/dist/audit.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":""}
@@ -0,0 +1,7 @@
1
+ export * from './vault.js';
2
+ export * from './notification.js';
3
+ export * from './apiToken.js';
4
+ export * from './audit.js';
5
+ export * from './locale.js';
6
+ export * from './safeStorage.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,mBAAmB,CAAA;AACjC,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from './vault.js';
2
+ export * from './notification.js';
3
+ export * from './apiToken.js';
4
+ export * from './audit.js';
5
+ export * from './locale.js';
6
+ export * from './safeStorage.js';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,mBAAmB,CAAA;AACjC,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA"}
@@ -0,0 +1,15 @@
1
+ export interface Locale<Key extends string = string> {
2
+ code: string;
3
+ name: string;
4
+ isDefault: number;
5
+ translations: Partial<Record<Key, string>>;
6
+ updatedAt: number;
7
+ }
8
+ export interface LocaleSummary {
9
+ code: string;
10
+ name: string;
11
+ isDefault?: number;
12
+ }
13
+ /** The language part of a locale code: "pl-PL" and "pl_PL" are both "pl". */
14
+ export declare function baseLanguage(code: string): string;
15
+ //# sourceMappingURL=locale.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale.d.ts","sourceRoot":"","sources":["../src/locale.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,MAAM,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM;IACjD,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAA;IAC1C,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjD"}
package/dist/locale.js ADDED
@@ -0,0 +1,5 @@
1
+ /** The language part of a locale code: "pl-PL" and "pl_PL" are both "pl". */
2
+ export function baseLanguage(code) {
3
+ return code.trim().toLowerCase().split(/[-_]/)[0] ?? '';
4
+ }
5
+ //# sourceMappingURL=locale.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale.js","sourceRoot":"","sources":["../src/locale.ts"],"names":[],"mappings":"AAcA,6EAA6E;AAC7E,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;AACzD,CAAC"}
@@ -0,0 +1,94 @@
1
+ import type { VaultRef } from './vault.js';
2
+ export type NotificationChannelType = 'email' | 'webhook' | 'discord' | 'teams' | 'slack' | 'telegram' | 'ntfy' | 'pushover';
3
+ export interface EmailNotificationConfig {
4
+ to: string;
5
+ subject: string;
6
+ body: string;
7
+ }
8
+ export interface WebhookNotificationConfig {
9
+ url: string;
10
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH';
11
+ headers?: Record<string, string>;
12
+ body?: string;
13
+ }
14
+ export interface DiscordNotificationConfig {
15
+ webhookUrl: string;
16
+ username?: string;
17
+ avatarUrl?: string;
18
+ content?: string;
19
+ }
20
+ export interface TeamsNotificationConfig {
21
+ webhookUrl: string;
22
+ summary?: string;
23
+ }
24
+ export interface SlackNotificationConfig {
25
+ webhookUrl: string;
26
+ text?: string;
27
+ }
28
+ export interface TelegramNotificationConfig {
29
+ /** Direct token; the API only ever returns it masked. Ignored when `vault` is set. */
30
+ botToken?: string;
31
+ /** Vault secret holding the token: a `value` secret. */
32
+ vault?: VaultRef;
33
+ chatId: string;
34
+ text?: string;
35
+ }
36
+ export interface NtfyNotificationConfig {
37
+ /** Server root; ntfy.sh when omitted. */
38
+ serverUrl?: string;
39
+ topic: string;
40
+ /** Access token of a protected topic; the API only ever returns it masked. Ignored when `vault` is set. */
41
+ accessToken?: string;
42
+ /** Vault secret holding the access token: a `value` secret. */
43
+ vault?: VaultRef;
44
+ text?: string;
45
+ }
46
+ export interface PushoverNotificationConfig {
47
+ /** Application token; the API only ever returns it masked. Ignored when `vault` is set. */
48
+ appToken?: string;
49
+ /** Vault secret holding the application token: a `value` secret. */
50
+ vault?: VaultRef;
51
+ /** User or group key that receives the messages. */
52
+ userKey: string;
53
+ text?: string;
54
+ }
55
+ /** The wording a channel has for one kind of event, by event type: only the message fields of its type (subject, body, text...). */
56
+ export interface ChannelEventOverrides {
57
+ eventOverrides?: Record<string, Record<string, string>>;
58
+ }
59
+ export type NotificationChannelConfig = (EmailNotificationConfig | WebhookNotificationConfig | DiscordNotificationConfig | TeamsNotificationConfig | SlackNotificationConfig | TelegramNotificationConfig | NtfyNotificationConfig | PushoverNotificationConfig) & ChannelEventOverrides;
60
+ /** Quiet hours silence a channel during a recurring local-time window. */
61
+ export interface QuietHoursPolicy {
62
+ enabled: boolean;
63
+ /** Local start of the quiet window, `HH:MM` (24h). */
64
+ start: string;
65
+ /** Local end of the quiet window, `HH:MM`. When <= start the window wraps past midnight. */
66
+ end: string;
67
+ /** IANA timezone the window is expressed in, e.g. `Europe/Warsaw`. */
68
+ timezone: string;
69
+ /** `defer` holds notifications until the window ends; `suppress` drops them. */
70
+ mode: 'defer' | 'suppress';
71
+ }
72
+ export type NotificationSuppressionReason = 'quiet-hours' | 'throttled' | 'grouped';
73
+ export interface SmtpSettings {
74
+ host: string;
75
+ port: number;
76
+ secure: number;
77
+ user: string;
78
+ password: string;
79
+ fromAddress: string;
80
+ fromName: string;
81
+ vault?: VaultRef | null;
82
+ updatedAt: number;
83
+ }
84
+ export type NotificationDeliveryStatus = 'pending' | 'delivered' | 'failed' | 'suppressed';
85
+ export interface NotificationDeliveryAttempt {
86
+ id: number;
87
+ deliveryId: number;
88
+ attemptNumber: number;
89
+ status: 'delivered' | 'failed';
90
+ error: string | null;
91
+ startedAt: number;
92
+ completedAt: number;
93
+ }
94
+ //# sourceMappingURL=notification.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification.d.ts","sourceRoot":"","sources":["../src/notification.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=notification.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification.js","sourceRoot":"","sources":["../src/notification.ts"],"names":[],"mappings":""}
@@ -0,0 +1,9 @@
1
+ /** Browser storage that never throws: a blocked or full storage reads as empty and drops writes. */
2
+ export interface SafeStorage {
3
+ get(key: string): string | null;
4
+ set(key: string, value: string): void;
5
+ remove(key: string): void;
6
+ }
7
+ export declare const safeLocalStorage: SafeStorage;
8
+ export declare const safeSessionStorage: SafeStorage;
9
+ //# sourceMappingURL=safeStorage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safeStorage.d.ts","sourceRoot":"","sources":["../src/safeStorage.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC/B,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B;AAUD,eAAO,MAAM,gBAAgB,aAA2B,CAAA;AACxD,eAAO,MAAM,kBAAkB,aAA6B,CAAA"}
@@ -0,0 +1,21 @@
1
+ function safe(area) {
2
+ return {
3
+ get(key) { try {
4
+ return area().getItem(key);
5
+ }
6
+ catch {
7
+ return null;
8
+ } },
9
+ set(key, value) { try {
10
+ area().setItem(key, value);
11
+ }
12
+ catch { /* storage unavailable: the value lasts for this page only */ } },
13
+ remove(key) { try {
14
+ area().removeItem(key);
15
+ }
16
+ catch { /* storage unavailable: nothing to remove */ } },
17
+ };
18
+ }
19
+ export const safeLocalStorage = safe(() => localStorage);
20
+ export const safeSessionStorage = safe(() => sessionStorage);
21
+ //# sourceMappingURL=safeStorage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safeStorage.js","sourceRoot":"","sources":["../src/safeStorage.ts"],"names":[],"mappings":"AAOA,SAAS,IAAI,CAAC,IAAmB;IAC/B,OAAO;QACL,GAAG,CAAC,GAAG,IAAI,IAAI,CAAC;YAAC,OAAO,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC,CAAC,CAAC;QACrE,GAAG,CAAC,GAAG,EAAE,KAAK,IAAI,IAAI,CAAC;YAAC,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,6DAA6D,CAAC,CAAC,CAAC,CAAC;QAC9H,MAAM,CAAC,GAAG,IAAI,IAAI,CAAC;YAAC,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,4CAA4C,CAAC,CAAC,CAAC,CAAC;KACtG,CAAA;AACH,CAAC;AAED,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,CAAA;AACxD,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,CAAA"}
@@ -0,0 +1,8 @@
1
+ /** Reference to a vault secret, with optional field mapping for json-type secrets */
2
+ export interface VaultRef {
3
+ vaultId: number;
4
+ secretId: number;
5
+ /** Only for json-type secrets: maps our field names to JSON object keys */
6
+ fieldMapping?: Record<string, string>;
7
+ }
8
+ //# sourceMappingURL=vault.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vault.d.ts","sourceRoot":"","sources":["../src/vault.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACtC"}
package/dist/vault.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=vault.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vault.js","sourceRoot":"","sources":["../src/vault.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
- {
2
- "name": "@better-forge/contracts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@better-forge/contracts",
3
+ "version": "0.1.0",
4
+ "description": "Browser-safe types, core translations and a storage wrapper that never throws.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/BElluu/Better-Forge.git",
9
+ "directory": "packages/contracts"
10
+ },
11
+ "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/contracts#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/BElluu/Better-Forge/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "!src/**/*.test.*"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.7.3"
39
+ },
40
+ "engines": {
41
+ "node": ">=26.10.0"
42
+ }
43
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * What an API token may do. A token carries a list of permissions instead of a role: each one names a part of the
3
+ * admin API and whether it may be read (`:read`) or changed (`:write`, which includes reading). `vault:use` is a
4
+ * separate permission to use secrets from a vault in a configuration.
5
+ */
6
+
7
+ export interface TokenResource {
8
+ key: string
9
+ label: string
10
+ description: string
11
+ /** `read` only for parts that cannot be changed through the API. */
12
+ levels: readonly ('read' | 'write')[]
13
+ }
14
+
15
+ /** Lets a token set, change or test a configuration that reads a secret from a vault. */
16
+ export const VAULT_USE_SCOPE = 'vault:use'
17
+
18
+ export interface TokenPreset { label: string; description: string; scopes: readonly string[] }
19
+
20
+ /**
21
+ * The permissions a token needs for a request: `<resource>:read` for GET and HEAD, `<resource>:write` for anything
22
+ * else. The vault catalogue is the one part with a single permission.
23
+ */
24
+ export function requiredScope(resource: string, method: string): string {
25
+ if (resource === 'vault') return VAULT_USE_SCOPE
26
+ return `${resource}:${method === 'GET' || method === 'HEAD' ? 'read' : 'write'}`
27
+ }
28
+
29
+ /** Whether a token with `granted` may do what needs `required`. Writing a part includes reading it. */
30
+ export function tokenAllows(granted: readonly string[], required: string): boolean {
31
+ if (granted.includes(required)) return true
32
+ return required.endsWith(':read') && granted.includes(`${required.slice(0, -':read'.length)}:write`)
33
+ }
34
+
35
+ /**
36
+ * A clean list of permissions out of `known` (reading added wherever writing is granted, ordered as `known` is), or
37
+ * null if one is unknown or none is given.
38
+ */
39
+ export function normalizeScopes(input: unknown, known: readonly string[]): string[] | null {
40
+ if (!Array.isArray(input) || input.length === 0 || !input.every((scope) => typeof scope === 'string' && known.includes(scope))) return null
41
+ const scopes = new Set<string>(input as string[])
42
+ for (const scope of input as string[]) if (scope.endsWith(':write') && known.includes(`${scope.slice(0, -':write'.length)}:read`)) scopes.add(`${scope.slice(0, -':write'.length)}:read`)
43
+ return known.filter((scope) => scopes.has(scope))
44
+ }
package/src/audit.ts ADDED
@@ -0,0 +1,33 @@
1
+ /** `allow` records a successful sign-in; `deny` a refused action, such as a sign-in with a wrong password. */
2
+ export type AuditAction = 'create' | 'update' | 'delete' | 'allow' | 'deny'
3
+
4
+ /** The kinds of object the shared modules write to the audit log; an app adds its own. */
5
+ export type KitAuditEntityType =
6
+ | 'notification_channel'
7
+ | 'smtp_settings'
8
+ | 'vault'
9
+ | 'vault_secret'
10
+ | 'user'
11
+ | 'api_token'
12
+ | 'oidc_settings'
13
+ /** Security changes on a user account: password, two-factor, SSO link. */
14
+ | 'user-security'
15
+ /** A configuration import; the entities it changed have entries of their own. */
16
+ | 'config'
17
+ | 'backup'
18
+ | 'backup-config'
19
+ | 'notification_delivery'
20
+ /** Every sign-in attempt, with a password or SSO; `diff.method` says which. */
21
+ | 'sign_in'
22
+
23
+ export interface AuditLogEntry<EntityType extends string = string> {
24
+ id: number
25
+ userId: number
26
+ userEmail: string
27
+ action: AuditAction
28
+ entityType: EntityType
29
+ entityId: string | null
30
+ entityName: string
31
+ diff: Record<string, unknown> | null
32
+ timestamp: number
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './vault.js'
2
+ export * from './notification.js'
3
+ export * from './apiToken.js'
4
+ export * from './audit.js'
5
+ export * from './locale.js'
6
+ export * from './safeStorage.js'
package/src/locale.ts ADDED
@@ -0,0 +1,18 @@
1
+ export interface Locale<Key extends string = string> {
2
+ code: string
3
+ name: string
4
+ isDefault: number
5
+ translations: Partial<Record<Key, string>>
6
+ updatedAt: number
7
+ }
8
+
9
+ export interface LocaleSummary {
10
+ code: string
11
+ name: string
12
+ isDefault?: number
13
+ }
14
+
15
+ /** The language part of a locale code: "pl-PL" and "pl_PL" are both "pl". */
16
+ export function baseLanguage(code: string): string {
17
+ return code.trim().toLowerCase().split(/[-_]/)[0] ?? ''
18
+ }
@@ -0,0 +1,110 @@
1
+ import type { VaultRef } from './vault.js'
2
+
3
+ export type NotificationChannelType = 'email' | 'webhook' | 'discord' | 'teams' | 'slack' | 'telegram' | 'ntfy' | 'pushover'
4
+
5
+ export interface EmailNotificationConfig {
6
+ to: string
7
+ subject: string
8
+ body: string
9
+ }
10
+
11
+ export interface WebhookNotificationConfig {
12
+ url: string
13
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH'
14
+ headers?: Record<string, string>
15
+ body?: string
16
+ }
17
+
18
+ export interface DiscordNotificationConfig {
19
+ webhookUrl: string
20
+ username?: string
21
+ avatarUrl?: string
22
+ content?: string
23
+ }
24
+
25
+ export interface TeamsNotificationConfig {
26
+ webhookUrl: string
27
+ summary?: string
28
+ }
29
+
30
+ export interface SlackNotificationConfig {
31
+ webhookUrl: string
32
+ text?: string
33
+ }
34
+
35
+ export interface TelegramNotificationConfig {
36
+ /** Direct token; the API only ever returns it masked. Ignored when `vault` is set. */
37
+ botToken?: string
38
+ /** Vault secret holding the token: a `value` secret. */
39
+ vault?: VaultRef
40
+ chatId: string
41
+ text?: string
42
+ }
43
+
44
+ export interface NtfyNotificationConfig {
45
+ /** Server root; ntfy.sh when omitted. */
46
+ serverUrl?: string
47
+ topic: string
48
+ /** Access token of a protected topic; the API only ever returns it masked. Ignored when `vault` is set. */
49
+ accessToken?: string
50
+ /** Vault secret holding the access token: a `value` secret. */
51
+ vault?: VaultRef
52
+ text?: string
53
+ }
54
+
55
+ export interface PushoverNotificationConfig {
56
+ /** Application token; the API only ever returns it masked. Ignored when `vault` is set. */
57
+ appToken?: string
58
+ /** Vault secret holding the application token: a `value` secret. */
59
+ vault?: VaultRef
60
+ /** User or group key that receives the messages. */
61
+ userKey: string
62
+ text?: string
63
+ }
64
+
65
+ /** The wording a channel has for one kind of event, by event type: only the message fields of its type (subject, body, text...). */
66
+ export interface ChannelEventOverrides {
67
+ eventOverrides?: Record<string, Record<string, string>>
68
+ }
69
+
70
+ export type NotificationChannelConfig = (EmailNotificationConfig | WebhookNotificationConfig | DiscordNotificationConfig | TeamsNotificationConfig | SlackNotificationConfig | TelegramNotificationConfig | NtfyNotificationConfig | PushoverNotificationConfig) & ChannelEventOverrides
71
+
72
+ /** Quiet hours silence a channel during a recurring local-time window. */
73
+ export interface QuietHoursPolicy {
74
+ enabled: boolean
75
+ /** Local start of the quiet window, `HH:MM` (24h). */
76
+ start: string
77
+ /** Local end of the quiet window, `HH:MM`. When <= start the window wraps past midnight. */
78
+ end: string
79
+ /** IANA timezone the window is expressed in, e.g. `Europe/Warsaw`. */
80
+ timezone: string
81
+ /** `defer` holds notifications until the window ends; `suppress` drops them. */
82
+ mode: 'defer' | 'suppress'
83
+ }
84
+
85
+ export type NotificationSuppressionReason = 'quiet-hours' | 'throttled' | 'grouped'
86
+
87
+
88
+ export interface SmtpSettings {
89
+ host: string
90
+ port: number
91
+ secure: number
92
+ user: string
93
+ password: string
94
+ fromAddress: string
95
+ fromName: string
96
+ vault?: VaultRef | null
97
+ updatedAt: number
98
+ }
99
+
100
+ export type NotificationDeliveryStatus = 'pending' | 'delivered' | 'failed' | 'suppressed'
101
+
102
+ export interface NotificationDeliveryAttempt {
103
+ id: number
104
+ deliveryId: number
105
+ attemptNumber: number
106
+ status: 'delivered' | 'failed'
107
+ error: string | null
108
+ startedAt: number
109
+ completedAt: number
110
+ }
@@ -0,0 +1,17 @@
1
+ /** Browser storage that never throws: a blocked or full storage reads as empty and drops writes. */
2
+ export interface SafeStorage {
3
+ get(key: string): string | null
4
+ set(key: string, value: string): void
5
+ remove(key: string): void
6
+ }
7
+
8
+ function safe(area: () => Storage): SafeStorage {
9
+ return {
10
+ get(key) { try { return area().getItem(key) } catch { return null } },
11
+ set(key, value) { try { area().setItem(key, value) } catch { /* storage unavailable: the value lasts for this page only */ } },
12
+ remove(key) { try { area().removeItem(key) } catch { /* storage unavailable: nothing to remove */ } },
13
+ }
14
+ }
15
+
16
+ export const safeLocalStorage = safe(() => localStorage)
17
+ export const safeSessionStorage = safe(() => sessionStorage)
package/src/vault.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** Reference to a vault secret, with optional field mapping for json-type secrets */
2
+ export interface VaultRef {
3
+ vaultId: number
4
+ secretId: number
5
+ /** Only for json-type secrets: maps our field names to JSON object keys */
6
+ fieldMapping?: Record<string, string>
7
+ }