@porulle/plugin-pos 0.7.0 → 0.9.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.
Files changed (38) hide show
  1. package/dist/hooks/checkout-pos.d.ts.map +1 -1
  2. package/dist/hooks/checkout-pos.js +9 -2
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +25 -1
  6. package/dist/routes/auth.d.ts +19 -0
  7. package/dist/routes/auth.d.ts.map +1 -0
  8. package/dist/routes/auth.js +66 -0
  9. package/dist/routes/exchanges.d.ts +15 -0
  10. package/dist/routes/exchanges.d.ts.map +1 -0
  11. package/dist/routes/exchanges.js +42 -0
  12. package/dist/routes/returns.d.ts.map +1 -1
  13. package/dist/routes/returns.js +25 -4
  14. package/dist/schema.d.ts +235 -0
  15. package/dist/schema.d.ts.map +1 -1
  16. package/dist/schema.js +25 -0
  17. package/dist/services/exchange-service.d.ts +55 -0
  18. package/dist/services/exchange-service.d.ts.map +1 -0
  19. package/dist/services/exchange-service.js +168 -0
  20. package/dist/services/pin-service.d.ts +73 -0
  21. package/dist/services/pin-service.d.ts.map +1 -0
  22. package/dist/services/pin-service.js +269 -0
  23. package/dist/services/transaction-service.d.ts.map +1 -1
  24. package/dist/services/transaction-service.js +5 -5
  25. package/dist/types.d.ts +16 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/types.js +1 -0
  28. package/package.json +3 -3
  29. package/src/hooks/checkout-pos.ts +9 -2
  30. package/src/index.ts +33 -0
  31. package/src/routes/auth.ts +72 -0
  32. package/src/routes/exchanges.ts +53 -0
  33. package/src/routes/returns.ts +47 -5
  34. package/src/schema.ts +29 -0
  35. package/src/services/exchange-service.ts +272 -0
  36. package/src/services/pin-service.ts +350 -0
  37. package/src/services/transaction-service.ts +5 -4
  38. package/src/types.ts +20 -0
@@ -0,0 +1,350 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import type { PluginResult } from "@porulle/core";
4
+ import { user } from "@porulle/core/auth-schema";
5
+ import { posOperatorPins, posPinAttempts, posShifts } from "../schema.js";
6
+ import type { Db, OperatorPin, PinAttempt, Shift } from "../types.js";
7
+
8
+ /**
9
+ * PIN auth runtime (issue #51).
10
+ *
11
+ * PINs hash with PBKDF2-SHA256 via Web Crypto (Workers-safe, no Node-only
12
+ * APIs). PIN login verifies the operator's PIN against their open shift and
13
+ * mints a short-lived Better Auth API key scoped to POS operation — the
14
+ * per-shift credential POS apps previously hand-rolled through module-global
15
+ * auth holders. Manager override verifies a PIN whose record carries
16
+ * `canOverride` and returns an audited approval.
17
+ */
18
+
19
+ const PBKDF2_ITERATIONS = 100_000;
20
+ const DEFAULT_LOCKOUT_MAX_ATTEMPTS = 5;
21
+ const DEFAULT_LOCKOUT_WINDOW_MINUTES = 15;
22
+ export const PIN_LOCKOUT_ERROR = "Too many failed PIN attempts; try again later";
23
+
24
+ type PinVerifyOutcome =
25
+ | { status: "ok"; record: OperatorPin }
26
+ | { status: "locked" }
27
+ | { status: "invalid" };
28
+
29
+ /** Narrow Better Auth surface the PIN runtime uses. */
30
+ export interface PinAuthApi {
31
+ api: {
32
+ createApiKey(input: { body: Record<string, unknown> }): Promise<unknown>;
33
+ };
34
+ }
35
+
36
+ function toBase64(bytes: Uint8Array): string {
37
+ let binary = "";
38
+ for (const b of bytes) binary += String.fromCharCode(b);
39
+ return btoa(binary);
40
+ }
41
+
42
+ function fromBase64(value: string): Uint8Array {
43
+ const binary = atob(value);
44
+ const bytes = new Uint8Array(binary.length);
45
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
46
+ return bytes;
47
+ }
48
+
49
+ async function deriveHash(pin: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
50
+ const keyMaterial = await crypto.subtle.importKey(
51
+ "raw",
52
+ new TextEncoder().encode(pin),
53
+ "PBKDF2",
54
+ false,
55
+ ["deriveBits"],
56
+ );
57
+ const bits = await crypto.subtle.deriveBits(
58
+ { name: "PBKDF2", hash: "SHA-256", salt: salt.slice().buffer as ArrayBuffer, iterations },
59
+ keyMaterial,
60
+ 256,
61
+ );
62
+ return new Uint8Array(bits);
63
+ }
64
+
65
+ export async function hashPin(pin: string): Promise<string> {
66
+ const salt = crypto.getRandomValues(new Uint8Array(16));
67
+ const hash = await deriveHash(pin, salt, PBKDF2_ITERATIONS);
68
+ return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`;
69
+ }
70
+
71
+ export async function verifyPinHash(pin: string, encoded: string): Promise<boolean> {
72
+ const [scheme, iterationsRaw, saltB64, hashB64] = encoded.split("$");
73
+ if (scheme !== "pbkdf2" || !iterationsRaw || !saltB64 || !hashB64) return false;
74
+ const expected = fromBase64(hashB64);
75
+ const actual = await deriveHash(pin, fromBase64(saltB64), Number(iterationsRaw));
76
+ if (actual.length !== expected.length) return false;
77
+ let diff = 0;
78
+ for (let i = 0; i < actual.length; i++) diff |= actual[i]! ^ expected[i]!;
79
+ return diff === 0;
80
+ }
81
+
82
+ async function ensureOperatorUser(db: Db, operatorId: string): Promise<void> {
83
+ // SEC-16/R-01: ensure ONLY a user row exists so a user-referenced API key can
84
+ // be minted. We create NO org membership and grant NO role — better-auth's
85
+ // org-referenced keys require owner/admin (a cashier->owner escalation), so
86
+ // the key is user-referenced and carries the operator's store in metadata,
87
+ // read back by authMiddleware.
88
+ const users = await db.select().from(user).where(eq(user.id, operatorId));
89
+ if (users.length === 0) {
90
+ await db.insert(user).values({
91
+ id: operatorId,
92
+ name: operatorId,
93
+ email: `${operatorId}@pos.local`,
94
+ emailVerified: true,
95
+ });
96
+ }
97
+ }
98
+
99
+ export class PinService {
100
+ constructor(
101
+ private db: Db,
102
+ private options: {
103
+ /** Named auth.apiKeyScopes config used to mint shift keys (optional). */
104
+ apiKeyScope?: string | undefined;
105
+ /** Shift-credential lifetime in seconds. Default: 12h. */
106
+ credentialTtlSeconds?: number | undefined;
107
+ /** Failed PIN attempts before lockout (SEC-15). Default: 5. */
108
+ lockoutMaxAttempts?: number | undefined;
109
+ /** Failure window and lockout duration in minutes (SEC-15). Default: 15. */
110
+ lockoutWindowMinutes?: number | undefined;
111
+ } = {},
112
+ ) {}
113
+
114
+ private lockoutMaxAttempts(): number {
115
+ return this.options.lockoutMaxAttempts ?? DEFAULT_LOCKOUT_MAX_ATTEMPTS;
116
+ }
117
+
118
+ private lockoutWindowMs(): number {
119
+ const minutes = this.options.lockoutWindowMinutes ?? DEFAULT_LOCKOUT_WINDOW_MINUTES;
120
+ return minutes * 60 * 1000;
121
+ }
122
+
123
+ private async getPinAttempt(
124
+ orgId: string,
125
+ operatorId: string,
126
+ ): Promise<PinAttempt | undefined> {
127
+ const rows = await this.db
128
+ .select()
129
+ .from(posPinAttempts)
130
+ .where(and(
131
+ eq(posPinAttempts.organizationId, orgId),
132
+ eq(posPinAttempts.operatorId, operatorId),
133
+ ));
134
+ return rows[0] as PinAttempt | undefined;
135
+ }
136
+
137
+ private isLockedOut(attempt: PinAttempt | undefined, now: Date): boolean {
138
+ return attempt?.lockedUntil != null && attempt.lockedUntil > now;
139
+ }
140
+
141
+ private async recordFailedPinAttempt(orgId: string, operatorId: string): Promise<void> {
142
+ const now = new Date();
143
+ const windowMs = this.lockoutWindowMs();
144
+ const maxAttempts = this.lockoutMaxAttempts();
145
+ const existing = await this.getPinAttempt(orgId, operatorId);
146
+
147
+ let failedCount = 1;
148
+ if (existing) {
149
+ const windowExpired = now.getTime() - existing.updatedAt.getTime() > windowMs;
150
+ failedCount = windowExpired ? 1 : existing.failedCount + 1;
151
+ }
152
+
153
+ const lockedUntil = failedCount >= maxAttempts
154
+ ? new Date(now.getTime() + windowMs)
155
+ : null;
156
+
157
+ if (existing) {
158
+ await this.db
159
+ .update(posPinAttempts)
160
+ .set({ failedCount, lockedUntil, updatedAt: now })
161
+ .where(eq(posPinAttempts.id, existing.id));
162
+ } else {
163
+ await this.db.insert(posPinAttempts).values({
164
+ organizationId: orgId,
165
+ operatorId,
166
+ failedCount,
167
+ lockedUntil,
168
+ });
169
+ }
170
+ }
171
+
172
+ private async clearPinAttempts(orgId: string, operatorId: string): Promise<void> {
173
+ const existing = await this.getPinAttempt(orgId, operatorId);
174
+ if (!existing) return;
175
+ await this.db
176
+ .update(posPinAttempts)
177
+ .set({ failedCount: 0, lockedUntil: null, updatedAt: new Date() })
178
+ .where(eq(posPinAttempts.id, existing.id));
179
+ }
180
+
181
+ async setPin(
182
+ orgId: string,
183
+ input: { operatorId: string; pin: string; canOverride?: boolean | undefined },
184
+ ): Promise<PluginResult<{ operatorId: string; canOverride: boolean }>> {
185
+ if (!/^\d{4,8}$/.test(input.pin)) {
186
+ return Err("PIN must be 4-8 digits");
187
+ }
188
+ const pinHash = await hashPin(input.pin);
189
+ const existing = await this.db
190
+ .select()
191
+ .from(posOperatorPins)
192
+ .where(and(
193
+ eq(posOperatorPins.organizationId, orgId),
194
+ eq(posOperatorPins.operatorId, input.operatorId),
195
+ ));
196
+ if (existing.length > 0) {
197
+ await this.db
198
+ .update(posOperatorPins)
199
+ .set({
200
+ pinHash,
201
+ canOverride: input.canOverride ?? (existing[0] as OperatorPin).canOverride,
202
+ updatedAt: new Date(),
203
+ })
204
+ .where(eq(posOperatorPins.id, (existing[0] as OperatorPin).id));
205
+ } else {
206
+ await this.db.insert(posOperatorPins).values({
207
+ organizationId: orgId,
208
+ operatorId: input.operatorId,
209
+ pinHash,
210
+ canOverride: input.canOverride ?? false,
211
+ });
212
+ }
213
+ return Ok({ operatorId: input.operatorId, canOverride: input.canOverride ?? false });
214
+ }
215
+
216
+ private async verifyOperatorPin(
217
+ orgId: string,
218
+ operatorId: string,
219
+ pin: string,
220
+ ): Promise<PinVerifyOutcome> {
221
+ const now = new Date();
222
+ const attempt = await this.getPinAttempt(orgId, operatorId);
223
+ if (this.isLockedOut(attempt, now)) return { status: "locked" };
224
+
225
+ const rows = await this.db
226
+ .select()
227
+ .from(posOperatorPins)
228
+ .where(and(
229
+ eq(posOperatorPins.organizationId, orgId),
230
+ eq(posOperatorPins.operatorId, operatorId),
231
+ ));
232
+ const record = rows[0] as OperatorPin | undefined;
233
+ if (!record) {
234
+ await this.recordFailedPinAttempt(orgId, operatorId);
235
+ return { status: "invalid" };
236
+ }
237
+
238
+ if (!(await verifyPinHash(pin, record.pinHash))) {
239
+ await this.recordFailedPinAttempt(orgId, operatorId);
240
+ return { status: "invalid" };
241
+ }
242
+
243
+ await this.clearPinAttempts(orgId, operatorId);
244
+ return { status: "ok", record };
245
+ }
246
+
247
+ /**
248
+ * PIN login: verifies the PIN, resolves the operator's open shift, and
249
+ * mints a short-lived API key bound to it. The terminal's device key
250
+ * authenticates this call (pos:operate); the returned key is personal.
251
+ */
252
+ async pinLogin(
253
+ orgId: string,
254
+ input: { operatorId: string; pin: string; shiftId?: string | undefined },
255
+ auth: PinAuthApi | undefined,
256
+ ): Promise<PluginResult<{
257
+ operatorId: string;
258
+ shiftId: string;
259
+ terminalId: string;
260
+ apiKey: string;
261
+ expiresAt: string;
262
+ }>> {
263
+ const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
264
+ if (verified.status === "locked") return Err(PIN_LOCKOUT_ERROR);
265
+ if (verified.status !== "ok") return Err("Invalid operator or PIN");
266
+
267
+ const conditions = [
268
+ eq(posShifts.organizationId, orgId),
269
+ eq(posShifts.operatorId, input.operatorId),
270
+ eq(posShifts.status, "open"),
271
+ ];
272
+ if (input.shiftId) conditions.push(eq(posShifts.id, input.shiftId));
273
+ const shifts = await this.db.select().from(posShifts).where(and(...conditions));
274
+ const shift = shifts[0] as Shift | undefined;
275
+ if (!shift) return Err("No open shift for this operator — open a shift first");
276
+
277
+ if (!auth) {
278
+ return Err("PIN login requires the Better Auth instance (plugin ctx.auth) — are routes mounted by createServer?");
279
+ }
280
+
281
+ await ensureOperatorUser(this.db, input.operatorId);
282
+
283
+ const ttlSeconds = this.options.credentialTtlSeconds ?? 12 * 3600;
284
+ let created: { key?: string; id?: string };
285
+ try {
286
+ created = (await auth.api.createApiKey({
287
+ body: {
288
+ // Better Auth caps name length at 32; the full shift id lives on the
289
+ // shift row (metadata.pinLoginApiKeyId links back).
290
+ name: `pos-shift-${shift.id.slice(0, 8)}`,
291
+ userId: input.operatorId,
292
+ organizationId: orgId,
293
+ expiresIn: ttlSeconds,
294
+ permissions: { pos: ["operate"] },
295
+ metadata: { operatorId: input.operatorId, organizationId: orgId },
296
+ // Shift/terminal binding is recorded on the shift row; the key name
297
+ // carries the shift id for traceability.
298
+ // The plugin registers this scope via its apiKeyScopes manifest.
299
+ configId: this.options.apiKeyScope ?? "pos",
300
+ },
301
+ })) as { key?: string; id?: string };
302
+ } catch (error) {
303
+ const message = error instanceof Error ? error.message : "Failed to mint shift credential";
304
+ return Err(message);
305
+ }
306
+ if (!created?.key) return Err("Failed to mint shift credential");
307
+
308
+ // Record the credential on the shift so admins can trace/revoke it.
309
+ await this.db
310
+ .update(posShifts)
311
+ .set({
312
+ metadata: { ...(shift.metadata ?? {}), pinLoginApiKeyId: created.id ?? null },
313
+ updatedAt: new Date(),
314
+ })
315
+ .where(eq(posShifts.id, shift.id));
316
+
317
+ return Ok({
318
+ operatorId: input.operatorId,
319
+ shiftId: shift.id,
320
+ terminalId: shift.terminalId,
321
+ apiKey: created.key,
322
+ expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Manager override: verifies the manager's PIN (record must carry
328
+ * canOverride) and returns an approval envelope for the requested action.
329
+ */
330
+ async override(
331
+ orgId: string,
332
+ input: { operatorId: string; pin: string; action: string },
333
+ ): Promise<PluginResult<{
334
+ approved: true;
335
+ operatorId: string;
336
+ action: string;
337
+ approvedAt: string;
338
+ }>> {
339
+ const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
340
+ if (verified.status === "locked") return Err(PIN_LOCKOUT_ERROR);
341
+ if (verified.status !== "ok") return Err("Invalid operator or PIN");
342
+ if (!verified.record.canOverride) return Err("This operator cannot approve overrides");
343
+ return Ok({
344
+ approved: true,
345
+ operatorId: input.operatorId,
346
+ action: input.action,
347
+ approvedAt: new Date().toISOString(),
348
+ });
349
+ }
350
+ }
@@ -25,7 +25,7 @@ export class TransactionService {
25
25
  .where(and(eq(posShifts.id, input.shiftId), eq(posShifts.status, "open")));
26
26
  if (shifts.length === 0) return Err("Shift is not open");
27
27
 
28
- const receiptNumber = await this.generateReceiptNumber(input.terminalId);
28
+ const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId);
29
29
 
30
30
  const rows = await this.db
31
31
  .insert(posTransactions)
@@ -121,7 +121,7 @@ export class TransactionService {
121
121
  await tx
122
122
  .update(posShifts)
123
123
  .set({ voidsCount: sql`${posShifts.voidsCount} + 1`, updatedAt: new Date() })
124
- .where(eq(posShifts.id, txn.shiftId));
124
+ .where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, orgId)));
125
125
 
126
126
  return Ok(updated[0]!);
127
127
  });
@@ -207,7 +207,7 @@ export class TransactionService {
207
207
  salesTotal: sql`${posShifts.salesTotal} + ${paymentTotal}`,
208
208
  updatedAt: new Date(),
209
209
  })
210
- .where(eq(posShifts.id, txn.shiftId));
210
+ .where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, txn.organizationId)));
211
211
  }
212
212
 
213
213
  return Ok(txn);
@@ -219,7 +219,7 @@ export class TransactionService {
219
219
  // ─── Receipt Number Generation ─────────────────────────────────────
220
220
  // Sequential per terminal per day: {terminal_code}-{sequence}
221
221
 
222
- private async generateReceiptNumber(terminalId: string): Promise<string> {
222
+ private async generateReceiptNumber(orgId: string, terminalId: string): Promise<string> {
223
223
  const today = new Date();
224
224
  const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
225
225
  const startOfDayISO = startOfDay.toISOString();
@@ -238,6 +238,7 @@ export class TransactionService {
238
238
  .select({ count: sql<number>`COUNT(*)`.as("count") })
239
239
  .from(posTransactions)
240
240
  .where(and(
241
+ eq(posTransactions.organizationId, orgId),
241
242
  eq(posTransactions.terminalId, terminalId),
242
243
  sql`${posTransactions.createdAt} >= ${startOfDayISO}::timestamptz`,
243
244
  ));
package/src/types.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  posTransactions,
7
7
  posPayments,
8
8
  posReturnItems,
9
+ posOperatorPins,
10
+ posPinAttempts,
9
11
  } from "./schema.js";
10
12
 
11
13
  export type Terminal = typeof posTerminals.$inferSelect;
@@ -26,6 +28,12 @@ export type PaymentInsert = typeof posPayments.$inferInsert;
26
28
  export type ReturnItem = typeof posReturnItems.$inferSelect;
27
29
  export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
28
30
 
31
+ export type OperatorPin = typeof posOperatorPins.$inferSelect;
32
+ export type OperatorPinInsert = typeof posOperatorPins.$inferInsert;
33
+
34
+ export type PinAttempt = typeof posPinAttempts.$inferSelect;
35
+ export type PinAttemptInsert = typeof posPinAttempts.$inferInsert;
36
+
29
37
  export type TransactionStatus = "open" | "held" | "completed" | "voided";
30
38
  export type TransactionType = "sale" | "return" | "exchange";
31
39
  export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
@@ -40,10 +48,22 @@ export interface POSPluginOptions {
40
48
  maxHoldHours?: number;
41
49
  /** Require manager override for discounts above this percentage. Default: 20 */
42
50
  discountOverrideThreshold?: number;
51
+ /** PIN auth runtime (issue #51). */
52
+ pinAuth?: {
53
+ /** Named auth.apiKeyScopes config used to mint per-shift keys. */
54
+ apiKeyScope?: string;
55
+ /** Shift-credential lifetime in seconds. Default: 43200 (12h). */
56
+ credentialTtlSeconds?: number;
57
+ /** Failed PIN attempts before lockout. Default: 5 (SEC-15). */
58
+ lockoutMaxAttempts?: number;
59
+ /** Failure counting window and lockout duration in minutes. Default: 15 (SEC-15). */
60
+ lockoutWindowMinutes?: number;
61
+ };
43
62
  }
44
63
 
45
64
  export const DEFAULT_POS_OPTIONS: Required<POSPluginOptions> = {
46
65
  defaultCurrency: "USD",
47
66
  maxHoldHours: 24,
48
67
  discountOverrideThreshold: 20,
68
+ pinAuth: {},
49
69
  };