@porulle/plugin-pos 0.6.0 → 0.8.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.
@@ -0,0 +1,62 @@
1
+ import type { PluginResult } from "@porulle/core";
2
+ import type { Db } from "../types.js";
3
+ /** Narrow Better Auth surface the PIN runtime uses. */
4
+ export interface PinAuthApi {
5
+ api: {
6
+ createApiKey(input: {
7
+ body: Record<string, unknown>;
8
+ }): Promise<unknown>;
9
+ };
10
+ }
11
+ export declare function hashPin(pin: string): Promise<string>;
12
+ export declare function verifyPinHash(pin: string, encoded: string): Promise<boolean>;
13
+ export declare class PinService {
14
+ private db;
15
+ private options;
16
+ constructor(db: Db, options?: {
17
+ /** Named auth.apiKeyScopes config used to mint shift keys (optional). */
18
+ apiKeyScope?: string | undefined;
19
+ /** Shift-credential lifetime in seconds. Default: 12h. */
20
+ credentialTtlSeconds?: number | undefined;
21
+ });
22
+ setPin(orgId: string, input: {
23
+ operatorId: string;
24
+ pin: string;
25
+ canOverride?: boolean | undefined;
26
+ }): Promise<PluginResult<{
27
+ operatorId: string;
28
+ canOverride: boolean;
29
+ }>>;
30
+ private verifyOperatorPin;
31
+ /**
32
+ * PIN login: verifies the PIN, resolves the operator's open shift, and
33
+ * mints a short-lived API key bound to it. The terminal's device key
34
+ * authenticates this call (pos:operate); the returned key is personal.
35
+ */
36
+ pinLogin(orgId: string, input: {
37
+ operatorId: string;
38
+ pin: string;
39
+ shiftId?: string | undefined;
40
+ }, auth: PinAuthApi | undefined): Promise<PluginResult<{
41
+ operatorId: string;
42
+ shiftId: string;
43
+ terminalId: string;
44
+ apiKey: string;
45
+ expiresAt: string;
46
+ }>>;
47
+ /**
48
+ * Manager override: verifies the manager's PIN (record must carry
49
+ * canOverride) and returns an approval envelope for the requested action.
50
+ */
51
+ override(orgId: string, input: {
52
+ operatorId: string;
53
+ pin: string;
54
+ action: string;
55
+ }): Promise<PluginResult<{
56
+ approved: true;
57
+ operatorId: string;
58
+ action: string;
59
+ approvedAt: string;
60
+ }>>;
61
+ }
62
+ //# sourceMappingURL=pin-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pin-service.d.ts","sourceRoot":"","sources":["../../src/services/pin-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAsB,MAAM,aAAa,CAAC;AAe1D,uDAAuD;AACvD,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE;QACH,YAAY,CAAC,KAAK,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;SAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;KAC1E,CAAC;CACH;AA+BD,wBAAsB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAI1D;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CASlF;AAED,qBAAa,UAAU;IAEnB,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,OAAO;gBADP,EAAE,EAAE,EAAE,EACN,OAAO,GAAE;QACf,yEAAyE;QACzE,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QACjC,0DAA0D;QAC1D,oBAAoB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;KACtC;IAGF,MAAM,CACV,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;KAAE,GAC5E,OAAO,CAAC,YAAY,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;YAgCxD,iBAAiB;IAiB/B;;;;OAIG;IACG,QAAQ,CACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,EACxE,IAAI,EAAE,UAAU,GAAG,SAAS,GAC3B,OAAO,CAAC,YAAY,CAAC;QACtB,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC,CAAC;IAsDH;;;OAGG;IACG,QAAQ,CACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GACzD,OAAO,CAAC,YAAY,CAAC;QACtB,QAAQ,EAAE,IAAI,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC,CAAC;CAWJ"}
@@ -0,0 +1,171 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import { posOperatorPins, posShifts } from "../schema.js";
4
+ /**
5
+ * PIN auth runtime (issue #51).
6
+ *
7
+ * PINs hash with PBKDF2-SHA256 via Web Crypto (Workers-safe, no Node-only
8
+ * APIs). PIN login verifies the operator's PIN against their open shift and
9
+ * mints a short-lived Better Auth API key scoped to POS operation — the
10
+ * per-shift credential POS apps previously hand-rolled through module-global
11
+ * auth holders. Manager override verifies a PIN whose record carries
12
+ * `canOverride` and returns an audited approval.
13
+ */
14
+ const PBKDF2_ITERATIONS = 100_000;
15
+ function toBase64(bytes) {
16
+ let binary = "";
17
+ for (const b of bytes)
18
+ binary += String.fromCharCode(b);
19
+ return btoa(binary);
20
+ }
21
+ function fromBase64(value) {
22
+ const binary = atob(value);
23
+ const bytes = new Uint8Array(binary.length);
24
+ for (let i = 0; i < binary.length; i++)
25
+ bytes[i] = binary.charCodeAt(i);
26
+ return bytes;
27
+ }
28
+ async function deriveHash(pin, salt, iterations) {
29
+ const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(pin), "PBKDF2", false, ["deriveBits"]);
30
+ const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: salt.slice().buffer, iterations }, keyMaterial, 256);
31
+ return new Uint8Array(bits);
32
+ }
33
+ export async function hashPin(pin) {
34
+ const salt = crypto.getRandomValues(new Uint8Array(16));
35
+ const hash = await deriveHash(pin, salt, PBKDF2_ITERATIONS);
36
+ return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`;
37
+ }
38
+ export async function verifyPinHash(pin, encoded) {
39
+ const [scheme, iterationsRaw, saltB64, hashB64] = encoded.split("$");
40
+ if (scheme !== "pbkdf2" || !iterationsRaw || !saltB64 || !hashB64)
41
+ return false;
42
+ const expected = fromBase64(hashB64);
43
+ const actual = await deriveHash(pin, fromBase64(saltB64), Number(iterationsRaw));
44
+ if (actual.length !== expected.length)
45
+ return false;
46
+ let diff = 0;
47
+ for (let i = 0; i < actual.length; i++)
48
+ diff |= actual[i] ^ expected[i];
49
+ return diff === 0;
50
+ }
51
+ export class PinService {
52
+ db;
53
+ options;
54
+ constructor(db, options = {}) {
55
+ this.db = db;
56
+ this.options = options;
57
+ }
58
+ async setPin(orgId, input) {
59
+ if (!/^\d{4,8}$/.test(input.pin)) {
60
+ return Err("PIN must be 4-8 digits");
61
+ }
62
+ const pinHash = await hashPin(input.pin);
63
+ const existing = await this.db
64
+ .select()
65
+ .from(posOperatorPins)
66
+ .where(and(eq(posOperatorPins.organizationId, orgId), eq(posOperatorPins.operatorId, input.operatorId)));
67
+ if (existing.length > 0) {
68
+ await this.db
69
+ .update(posOperatorPins)
70
+ .set({
71
+ pinHash,
72
+ canOverride: input.canOverride ?? existing[0].canOverride,
73
+ updatedAt: new Date(),
74
+ })
75
+ .where(eq(posOperatorPins.id, existing[0].id));
76
+ }
77
+ else {
78
+ await this.db.insert(posOperatorPins).values({
79
+ organizationId: orgId,
80
+ operatorId: input.operatorId,
81
+ pinHash,
82
+ canOverride: input.canOverride ?? false,
83
+ });
84
+ }
85
+ return Ok({ operatorId: input.operatorId, canOverride: input.canOverride ?? false });
86
+ }
87
+ async verifyOperatorPin(orgId, operatorId, pin) {
88
+ const rows = await this.db
89
+ .select()
90
+ .from(posOperatorPins)
91
+ .where(and(eq(posOperatorPins.organizationId, orgId), eq(posOperatorPins.operatorId, operatorId)));
92
+ const record = rows[0];
93
+ if (!record)
94
+ return null;
95
+ return (await verifyPinHash(pin, record.pinHash)) ? record : null;
96
+ }
97
+ /**
98
+ * PIN login: verifies the PIN, resolves the operator's open shift, and
99
+ * mints a short-lived API key bound to it. The terminal's device key
100
+ * authenticates this call (pos:operate); the returned key is personal.
101
+ */
102
+ async pinLogin(orgId, input, auth) {
103
+ const record = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
104
+ if (!record)
105
+ return Err("Invalid operator or PIN");
106
+ const conditions = [
107
+ eq(posShifts.organizationId, orgId),
108
+ eq(posShifts.operatorId, input.operatorId),
109
+ eq(posShifts.status, "open"),
110
+ ];
111
+ if (input.shiftId)
112
+ conditions.push(eq(posShifts.id, input.shiftId));
113
+ const shifts = await this.db.select().from(posShifts).where(and(...conditions));
114
+ const shift = shifts[0];
115
+ if (!shift)
116
+ return Err("No open shift for this operator — open a shift first");
117
+ if (!auth) {
118
+ return Err("PIN login requires the Better Auth instance (plugin ctx.auth) — are routes mounted by createServer?");
119
+ }
120
+ const ttlSeconds = this.options.credentialTtlSeconds ?? 12 * 3600;
121
+ const created = (await auth.api.createApiKey({
122
+ body: {
123
+ // Better Auth caps name length at 32; the full shift id lives on the
124
+ // shift row (metadata.pinLoginApiKeyId links back).
125
+ name: `pos-shift-${shift.id.slice(0, 8)}`,
126
+ userId: input.operatorId,
127
+ expiresIn: ttlSeconds,
128
+ permissions: { pos: ["operate"] },
129
+ // Shift/terminal binding is recorded on the shift row (metadata is
130
+ // disabled by default in Better Auth's apiKey plugin); the key name
131
+ // carries the shift id for traceability.
132
+ // The plugin registers this scope via its apiKeyScopes manifest.
133
+ configId: this.options.apiKeyScope ?? "pos",
134
+ },
135
+ }));
136
+ if (!created?.key)
137
+ return Err("Failed to mint shift credential");
138
+ // Record the credential on the shift so admins can trace/revoke it.
139
+ await this.db
140
+ .update(posShifts)
141
+ .set({
142
+ metadata: { ...(shift.metadata ?? {}), pinLoginApiKeyId: created.id ?? null },
143
+ updatedAt: new Date(),
144
+ })
145
+ .where(eq(posShifts.id, shift.id));
146
+ return Ok({
147
+ operatorId: input.operatorId,
148
+ shiftId: shift.id,
149
+ terminalId: shift.terminalId,
150
+ apiKey: created.key,
151
+ expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
152
+ });
153
+ }
154
+ /**
155
+ * Manager override: verifies the manager's PIN (record must carry
156
+ * canOverride) and returns an approval envelope for the requested action.
157
+ */
158
+ async override(orgId, input) {
159
+ const record = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
160
+ if (!record)
161
+ return Err("Invalid operator or PIN");
162
+ if (!record.canOverride)
163
+ return Err("This operator cannot approve overrides");
164
+ return Ok({
165
+ approved: true,
166
+ operatorId: input.operatorId,
167
+ action: input.action,
168
+ approvedAt: new Date().toISOString(),
169
+ });
170
+ }
171
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { PluginDb as Db } from "@porulle/core";
2
- import type { posTerminals, posShifts, posCashEvents, posTransactions, posPayments, posReturnItems } from "./schema.js";
2
+ import type { posTerminals, posShifts, posCashEvents, posTransactions, posPayments, posReturnItems, posOperatorPins } from "./schema.js";
3
3
  export type Terminal = typeof posTerminals.$inferSelect;
4
4
  export type TerminalInsert = typeof posTerminals.$inferInsert;
5
5
  export type Shift = typeof posShifts.$inferSelect;
@@ -12,6 +12,8 @@ export type Payment = typeof posPayments.$inferSelect;
12
12
  export type PaymentInsert = typeof posPayments.$inferInsert;
13
13
  export type ReturnItem = typeof posReturnItems.$inferSelect;
14
14
  export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
15
+ export type OperatorPin = typeof posOperatorPins.$inferSelect;
16
+ export type OperatorPinInsert = typeof posOperatorPins.$inferInsert;
15
17
  export type TransactionStatus = "open" | "held" | "completed" | "voided";
16
18
  export type TransactionType = "sale" | "return" | "exchange";
17
19
  export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
@@ -25,6 +27,13 @@ export interface POSPluginOptions {
25
27
  maxHoldHours?: number;
26
28
  /** Require manager override for discounts above this percentage. Default: 20 */
27
29
  discountOverrideThreshold?: number;
30
+ /** PIN auth runtime (issue #51). */
31
+ pinAuth?: {
32
+ /** Named auth.apiKeyScopes config used to mint per-shift keys. */
33
+ apiKeyScope?: string;
34
+ /** Shift-credential lifetime in seconds. Default: 43200 (12h). */
35
+ credentialTtlSeconds?: number;
36
+ };
28
37
  }
29
38
  export declare const DEFAULT_POS_OPTIONS: Required<POSPluginOptions>;
30
39
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EACV,YAAY,EACZ,SAAS,EACT,aAAa,EACb,eAAe,EACf,WAAW,EACX,cAAc,EACf,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,QAAQ,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AACxD,MAAM,MAAM,cAAc,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AAE9D,MAAM,MAAM,KAAK,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAClD,MAAM,MAAM,WAAW,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAExD,MAAM,MAAM,SAAS,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAC1D,MAAM,MAAM,eAAe,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAEhE,MAAM,MAAM,WAAW,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAC9D,MAAM,MAAM,iBAAiB,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAEpE,MAAM,MAAM,OAAO,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AACtD,MAAM,MAAM,aAAa,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AAE5D,MAAM,MAAM,UAAU,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC5D,MAAM,MAAM,gBAAgB,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAElE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AACzE,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;AAC7D,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AACrF,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAC;AACjF,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,QAAQ,CAAC;AAC5C,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEtE,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,yBAAyB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,gBAAgB,CAI1D,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EACV,YAAY,EACZ,SAAS,EACT,aAAa,EACb,eAAe,EACf,WAAW,EACX,cAAc,EACd,eAAe,EAChB,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,QAAQ,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AACxD,MAAM,MAAM,cAAc,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AAE9D,MAAM,MAAM,KAAK,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAClD,MAAM,MAAM,WAAW,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAExD,MAAM,MAAM,SAAS,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAC1D,MAAM,MAAM,eAAe,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAEhE,MAAM,MAAM,WAAW,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAC9D,MAAM,MAAM,iBAAiB,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAEpE,MAAM,MAAM,OAAO,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AACtD,MAAM,MAAM,aAAa,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AAE5D,MAAM,MAAM,UAAU,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC5D,MAAM,MAAM,gBAAgB,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAElE,MAAM,MAAM,WAAW,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAC9D,MAAM,MAAM,iBAAiB,GAAG,OAAO,eAAe,CAAC,YAAY,CAAC;AAEpE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AACzE,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;AAC7D,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AACrF,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAC;AACjF,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,QAAQ,CAAC;AAC5C,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEtE,MAAM,WAAW,gBAAgB;IAC/B,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,oCAAoC;IACpC,OAAO,CAAC,EAAE;QACR,kEAAkE;QAClE,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,kEAAkE;QAClE,oBAAoB,CAAC,EAAE,MAAM,CAAC;KAC/B,CAAC;CACH;AAED,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,gBAAgB,CAK1D,CAAC"}
package/dist/types.js CHANGED
@@ -2,4 +2,5 @@ export const DEFAULT_POS_OPTIONS = {
2
2
  defaultCurrency: "USD",
3
3
  maxHoldHours: 24,
4
4
  discountOverrideThreshold: 20,
5
+ pinAuth: {},
5
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-pos",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,8 +19,8 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.6.0",
23
- "@porulle/db": "0.6.0"
22
+ "@porulle/core": "0.8.0",
23
+ "@porulle/db": "0.8.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  posTransactions,
7
7
  posPayments,
8
8
  posReturnItems,
9
+ posOperatorPins,
9
10
  } from "./schema.js";
10
11
  import { TerminalService } from "./services/terminal-service.js";
11
12
  import { ShiftService } from "./services/shift-service.js";
@@ -14,6 +15,8 @@ import { PaymentService } from "./services/payment-service.js";
14
15
  import { ReturnService } from "./services/return-service.js";
15
16
  import { LookupService } from "./services/lookup-service.js";
16
17
  import { ReceiptService } from "./services/receipt-service.js";
18
+ import { PinService } from "./services/pin-service.js";
19
+ import { ExchangeService } from "./services/exchange-service.js";
17
20
  import { buildTerminalRoutes } from "./routes/terminals.js";
18
21
  import { buildShiftRoutes } from "./routes/shifts.js";
19
22
  import { buildTransactionRoutes } from "./routes/transactions.js";
@@ -21,6 +24,8 @@ import { buildPaymentRoutes } from "./routes/payments.js";
21
24
  import { buildReturnRoutes } from "./routes/returns.js";
22
25
  import { buildLookupRoutes } from "./routes/lookup.js";
23
26
  import { buildReceiptRoutes } from "./routes/receipts.js";
27
+ import { buildPinAuthRoutes } from "./routes/auth.js";
28
+ import { buildExchangeRoutes } from "./routes/exchanges.js";
24
29
  import { buildPOSShippingHook, buildPOSFinalizationHook } from "./hooks/checkout-pos.js";
25
30
  import type { POSPluginOptions, Db } from "./types.js";
26
31
  import { DEFAULT_POS_OPTIONS } from "./types.js";
@@ -33,6 +38,8 @@ export { PaymentService } from "./services/payment-service.js";
33
38
  export { ReturnService } from "./services/return-service.js";
34
39
  export { LookupService } from "./services/lookup-service.js";
35
40
  export { ReceiptService } from "./services/receipt-service.js";
41
+ export { PinService, hashPin, verifyPinHash } from "./services/pin-service.js";
42
+ export { ExchangeService } from "./services/exchange-service.js";
36
43
  export { createPOSPaymentAdapter } from "./payment-adapter.js";
37
44
 
38
45
  /**
@@ -84,6 +91,19 @@ export function posPlugin(userOptions: POSPluginOptions = {}) {
84
91
  posTransactions,
85
92
  posPayments,
86
93
  posReturnItems,
94
+ posOperatorPins,
95
+ }),
96
+
97
+ // Scope used by PIN login to mint per-shift credentials (issue #51).
98
+ apiKeyScopes: () => ({
99
+ [options.pinAuth.apiKeyScope ?? "pos"]: {
100
+ prefix: "pos_shift_",
101
+ description: "Short-lived per-shift POS credentials minted by PIN login.",
102
+ permissions: { pos: ["operate"] },
103
+ // Allow credentials as short as 15 minutes (Better Auth default
104
+ // minimum is 1 day — too long for per-shift keys).
105
+ keyExpiration: { minExpiresIn: 1 / 96 },
106
+ },
87
107
  }),
88
108
 
89
109
  hooks: () => [
@@ -111,6 +131,14 @@ export function posPlugin(userOptions: POSPluginOptions = {}) {
111
131
  const returnService = new ReturnService(db);
112
132
  const lookupService = new LookupService(db, ctx.services);
113
133
  const receiptService = new ReceiptService(db, ctx.services);
134
+ const pinService = new PinService(db, options.pinAuth);
135
+ const exchangeService = new ExchangeService(
136
+ db,
137
+ ctx.services,
138
+ transactionFn as <T>(fn: (tx: typeof db) => Promise<T>) => Promise<T>,
139
+ transactionService,
140
+ returnService,
141
+ );
114
142
 
115
143
  // Cart service from core (for creating POS transaction carts)
116
144
  const cartService = ctx.services.cart as {
@@ -125,6 +153,8 @@ export function posPlugin(userOptions: POSPluginOptions = {}) {
125
153
  ...buildReturnRoutes(returnService, transactionService, paymentService, cartService, ctx),
126
154
  ...buildLookupRoutes(lookupService, ctx),
127
155
  ...buildReceiptRoutes(receiptService, ctx),
156
+ ...buildPinAuthRoutes(pinService, ctx),
157
+ ...buildExchangeRoutes(exchangeService, ctx),
128
158
  ];
129
159
  },
130
160
  });
@@ -0,0 +1,67 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { PinService, PinAuthApi } from "../services/pin-service.js";
4
+ import type { PluginRouteRegistration } from "@porulle/core";
5
+
6
+ /**
7
+ * PIN auth runtime (issue #51).
8
+ *
9
+ * - PUT /pos/auth/pin — set/rotate an operator's PIN (pos:admin)
10
+ * - POST /pos/auth/pin-login — operator PIN → short-lived per-shift API key.
11
+ * Authenticated by the terminal's device credential (pos:operate); the
12
+ * minted key is personal and carries pos:operate + shift/terminal metadata.
13
+ * - POST /pos/auth/override — manager PIN approves one elevated action.
14
+ */
15
+ export function buildPinAuthRoutes(
16
+ service: PinService,
17
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown }; auth?: unknown },
18
+ ): PluginRouteRegistration[] {
19
+ const r = router("POS Auth", "/pos/auth", ctx);
20
+
21
+ r.put("/pin")
22
+ .summary("Set or rotate an operator PIN")
23
+ .permission("pos:admin")
24
+ .input(z.object({
25
+ operatorId: z.string().min(1),
26
+ pin: z.string().regex(/^\d{4,8}$/, "PIN must be 4-8 digits"),
27
+ canOverride: z.boolean().optional(),
28
+ }))
29
+ .handler(async ({ input, orgId }) => {
30
+ const body = input as { operatorId: string; pin: string; canOverride?: boolean };
31
+ const result = await service.setPin(orgId, body);
32
+ if (!result.ok) throw new Error(result.error);
33
+ return result.value;
34
+ });
35
+
36
+ r.post("/pin-login")
37
+ .summary("PIN login — mint a short-lived per-shift credential")
38
+ .permission("pos:operate")
39
+ .input(z.object({
40
+ operatorId: z.string().min(1),
41
+ pin: z.string().regex(/^\d{4,8}$/),
42
+ shiftId: z.string().uuid().optional(),
43
+ }))
44
+ .handler(async ({ input, orgId }) => {
45
+ const body = input as { operatorId: string; pin: string; shiftId?: string };
46
+ const result = await service.pinLogin(orgId, body, ctx.auth as PinAuthApi | undefined);
47
+ if (!result.ok) throw new Error(result.error);
48
+ return result.value;
49
+ });
50
+
51
+ r.post("/override")
52
+ .summary("Manager override — approve one elevated action by PIN")
53
+ .permission("pos:operate")
54
+ .input(z.object({
55
+ operatorId: z.string().min(1),
56
+ pin: z.string().regex(/^\d{4,8}$/),
57
+ action: z.string().min(1).max(200),
58
+ }))
59
+ .handler(async ({ input, orgId }) => {
60
+ const body = input as { operatorId: string; pin: string; action: string };
61
+ const result = await service.override(orgId, body);
62
+ if (!result.ok) throw new Error(result.error);
63
+ return result.value;
64
+ });
65
+
66
+ return r.routes();
67
+ }
@@ -0,0 +1,53 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { ExchangeService, ExchangeInput } from "../services/exchange-service.js";
4
+ import type { PluginRouteRegistration } from "@porulle/core";
5
+
6
+ /**
7
+ * Exchanges (issue #53): POST /pos/exchanges — return + replacement order +
8
+ * net delta in one call. Requires pos:manage (like returns) plus core order
9
+ * scopes on the actor (orders:update for the refund, orders:create for the
10
+ * replacement) since the exchange moves money on core orders.
11
+ */
12
+ export function buildExchangeRoutes(
13
+ service: ExchangeService,
14
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
15
+ ): PluginRouteRegistration[] {
16
+ const r = router("POS Exchanges", "/pos/exchanges", ctx);
17
+
18
+ r.post("/")
19
+ .summary("Exchange — return lines + replacement order in one atomic operation")
20
+ .permission("pos:manage")
21
+ .input(z.object({
22
+ shiftId: z.string().uuid(),
23
+ terminalId: z.string().uuid(),
24
+ originalOrderId: z.string().uuid(),
25
+ currency: z.string().length(3).optional(),
26
+ customerId: z.string().uuid().optional(),
27
+ returnItems: z.array(z.object({
28
+ originalLineItemId: z.string().uuid(),
29
+ quantity: z.number().int().positive(),
30
+ reason: z.enum(["defective", "wrong_item", "changed_mind", "other"]),
31
+ })).min(1),
32
+ replacementItems: z.array(z.object({
33
+ entityId: z.string().uuid(),
34
+ variantId: z.string().uuid().optional(),
35
+ sku: z.string().optional(),
36
+ title: z.string().min(1),
37
+ quantity: z.number().int().positive(),
38
+ unitPrice: z.number().int().min(0),
39
+ taxAmount: z.number().int().min(0).optional(),
40
+ })).min(1),
41
+ }))
42
+ .handler(async ({ input, actor, orgId }) => {
43
+ const result = await service.exchange(
44
+ orgId,
45
+ input as ExchangeInput,
46
+ actor as { userId: string } & Record<string, unknown>,
47
+ );
48
+ if (!result.ok) throw new Error(result.error);
49
+ return result.value;
50
+ });
51
+
52
+ return r.routes();
53
+ }
package/src/schema.ts CHANGED
@@ -118,6 +118,22 @@ export const posPayments = pgTable("pos_payments", {
118
118
  transactionIdx: index("idx_pos_payments_transaction").on(table.transactionId),
119
119
  }));
120
120
 
121
+ // ─── Operator PINs (issue #51) ──────────────────────────────────────────
122
+
123
+ export const posOperatorPins = pgTable("pos_operator_pins", {
124
+ id: uuid("id").defaultRandom().primaryKey(),
125
+ organizationId: text("organization_id").notNull(),
126
+ operatorId: text("operator_id").notNull(),
127
+ // PBKDF2-SHA256, encoded as pbkdf2$<iterations>$<saltB64>$<hashB64>
128
+ pinHash: text("pin_hash").notNull(),
129
+ // Manager override capability — checked by POST /pos/auth/override.
130
+ canOverride: boolean("can_override").notNull().default(false),
131
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
132
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
133
+ }, (table) => ({
134
+ orgOperatorUnique: uniqueIndex("pos_operator_pins_org_operator_unique").on(table.organizationId, table.operatorId),
135
+ }));
136
+
121
137
  // ─── Return Items ───────────────────────────────────────────────────────
122
138
 
123
139
  export const posReturnItems = pgTable("pos_return_items", {