gymmonk-schema 0.28.0 → 0.28.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"location.d.ts","sourceRoot":"","sources":["../src/location.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAYxB,+DAA+D;AAC/D,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAWpD;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gBAAgB,aAMzB,CAAC;AAEL,uEAAuE;AACvE,eAAO,MAAM,kBAAkB;;iBAE7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;iBAIxB,CAAC;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,cAAc;;;iBAGzB,CAAC;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEtD;;;;;GAKG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;iBAGzB,CAAC;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAItD,8CAA8C;AAC9C,eAAO,MAAM,gBAAgB,iBAAiB,CAAC;AAE/C;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAQtD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,2BAIC,CAAC;AAI7B;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB;;;;iBAUhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
1
+ {"version":3,"file":"location.d.ts","sourceRoot":"","sources":["../src/location.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAYxB,+DAA+D;AAC/D,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAWpD;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gBAAgB,aAMzB,CAAC;AAEL,uEAAuE;AACvE,eAAO,MAAM,kBAAkB;;iBAE7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;iBAIxB,CAAC;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,cAAc;;;iBAGzB,CAAC;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEtD;;;;;GAKG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;iBAGzB,CAAC;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAItD,8CAA8C;AAC9C,eAAO,MAAM,gBAAgB,iBAAiB,CAAC;AAE/C;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAmBtD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,2BAIC,CAAC;AAI7B;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB;;;;iBAUhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
package/dist/location.js CHANGED
@@ -120,6 +120,18 @@ export const DEFAULT_TIMEZONE = 'Asia/Kolkata';
120
120
  export function isValidTimeZone(value) {
121
121
  if (!value)
122
122
  return false;
123
+ /**
124
+ * Offset forms are rejected explicitly, and this line is the whole reason the
125
+ * check is not a bare try/catch.
126
+ *
127
+ * ECMA-402 lets `Intl.DateTimeFormat` accept an OFFSET as a time zone, so
128
+ * `"+05:30"` constructs perfectly happily. It is still not a timezone: an
129
+ * offset cannot say when the clocks change, so storing one gives a gym a day
130
+ * boundary that is right until DST and silently wrong afterwards — in exactly
131
+ * the zones a global rollout would add.
132
+ */
133
+ if (/^[+-]\d{2}:?\d{2}$/.test(value.trim()))
134
+ return false;
123
135
  try {
124
136
  new Intl.DateTimeFormat('en-US', { timeZone: value });
125
137
  return true;
@@ -1 +1 @@
1
- {"version":3,"file":"location.js","sourceRoot":"","sources":["../src/location.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Where a place physically is\n * ============================================\n * The pasted Google Maps link an owner gives for their headquarters and each\n * centre, and the coordinates read out of it.\n *\n * VALIDATION LIVES HERE, RESOLUTION DOES NOT. Deciding whether a string is a\n * Google Maps link is pure and belongs in one place for both repos. FOLLOWING\n * that link — a server-side fetch of a short URL — stays in gymmonk-backend\n * (`shared/lib/resolve-map-link`), because it is I/O and because it is the\n * SSRF-shaped half that must never run in a browser.\n *\n * WHY THE CLIENT'S COORDINATES ARE NEVER TRUSTED\n * ----------------------------------------------\n * The registration form previews the pin as you paste, so the browser already\n * holds `{lat, lng}` and could simply send them. It must not, and no body schema\n * here accepts them. That pin decides whether a member's check-in counts, so\n * taking it from the client would let anyone replaying the request place a gym\n * on top of a member's sofa and have the geofence agree. The URL is evidence;\n * the coordinates are a conclusion, and the server draws it.\n *\n * @module gymmonk-schema/location\n */\n\nimport { z } from 'zod';\n\n/** Hosts whose links are treated as Google maps. */\nconst MAP_HOSTS = new Set([\n 'maps.google.com',\n 'www.google.com',\n 'google.com',\n 'maps.app.goo.gl',\n 'goo.gl',\n 'g.co',\n]);\n\n/** True for a link we are willing to treat as a Google map. */\nexport function isGoogleMapsUrl(raw: string): boolean {\n try {\n const url = new URL(raw.trim());\n if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;\n const host = url.hostname.toLowerCase();\n // `google.co.in`, `google.co.uk` and the rest of the country domains all\n // serve the same /maps paths.\n return MAP_HOSTS.has(host) || /(^|\\.)google\\.[a-z.]{2,6}$/.test(host);\n } catch {\n return false;\n }\n}\n\n/**\n * The pasted link, validated only as far as \"plausibly a Google map\".\n *\n * Deliberately permissive about FORM and strict about HOST. The backend's\n * resolver performs an outbound request for short links, so the host allowlist\n * is a security control; the rest of the URL can be any of the half-dozen shapes\n * Maps emits, and rejecting one because it did not match a pattern would be\n * telling an owner their own share link is wrong.\n *\n * An empty string is accepted and means \"remove the link\", which is why this\n * cannot simply be `.url()`.\n */\nexport const mapLinkUrlSchema = z\n .string()\n .trim()\n .max(2000)\n .refine((v) => v === '' || isGoogleMapsUrl(v), {\n message: 'Paste a Google Maps link (from Share → Copy link)',\n });\n\n/** Mix into any create/update body that describes a physical place. */\nexport const mapLinkInputSchema = z.object({\n mapLinkUrl: mapLinkUrlSchema.optional(),\n});\nexport type MapLinkInput = z.infer<typeof mapLinkInputSchema>;\n\n/**\n * What the server stored for a resolved link.\n *\n * `lat`/`lng` are absent when the link carried no readable pin. The link is\n * still kept: \"Open in Maps\" works from the url alone, and an owner who pasted\n * something should not find the field empty next time they open the form. It\n * simply means proximity stays unenforceable for that gym, which is the truth.\n */\nexport const mapLinkSchema = z.object({\n url: z.string(),\n lat: z.number().optional(),\n lng: z.number().optional(),\n});\nexport type MapLink = z.infer<typeof mapLinkSchema>;\n\n/**\n * A GeoJSON point, in MongoDB's order.\n *\n * `[lng, lat]`, NOT `[lat, lng]`. Reversing them is the classic 2dsphere bug and\n * it does not throw — it silently puts every Indian gym in the Indian Ocean and\n * the geofence then refuses every check-in.\n */\nexport const geoPointSchema = z.object({\n type: z.literal('Point'),\n coordinates: z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]),\n});\nexport type GeoPoint = z.infer<typeof geoPointSchema>;\n\n/**\n * What a place exposes about its position on the wire.\n *\n * Both optional: no gym is required to have a pin, and one that does not simply\n * cannot have attendance verified against it.\n */\nexport const placePinSchema = z.object({\n mapLink: mapLinkSchema.optional(),\n location: geoPointSchema.optional(),\n});\nexport type PlacePin = z.infer<typeof placePinSchema>;\n\n// ─── Timezone ────────────────────────────────────────────────────────────────\n\n/** Every gym is in India until one is not. */\nexport const DEFAULT_TIMEZONE = 'Asia/Kolkata';\n\n/**\n * Is this a timezone the runtime actually knows?\n *\n * Validated rather than trusted, because an unknown identifier does not degrade\n * gracefully — `Intl.DateTimeFormat` THROWS on one. A bad value stored on a\n * centre would take down every attendance write for that gym, at the moment a\n * member scans, which is the worst possible time to discover a typo.\n *\n * Shared so the server validates before storing and the client can validate\n * before offering a choice.\n */\nexport function isValidTimeZone(value: string): boolean {\n if (!value) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * An IANA timezone identifier — `Asia/Kolkata`, `America/New_York`.\n *\n * A NAME, never a UTC offset. An offset is a property of an instant rather than\n * of a place: it moves with DST, so a stored `+05:30` is already wrong for half\n * the year anywhere that observes it, and cannot answer \"when does this gym's\n * day start\" in a month's time.\n */\nexport const timezoneSchema = z\n .string()\n .trim()\n .refine(isValidTimeZone, { message: 'Unknown timezone' })\n .default(DEFAULT_TIMEZONE);\n\n// ─── Check-in position ───────────────────────────────────────────────────────\n\n/**\n * The position a check-in request carries: where the scanning device thought it\n * was, and how sure it was.\n *\n * WHY ON THE REQUEST, AND NOT READ FROM THE USER'S STORED FIX\n * The app records a member's location trail separately, and reading the latest\n * entry here would be wrong: that write happens BESIDE the scan, not before it,\n * so at the moment check-in reaches the server the trail may hold a fix from an\n * hour ago, or nothing. Attendance would then be verified against where the\n * member happened to be earlier, which is evidence of nothing.\n *\n * ALL THREE ARE OPTIONAL and stay that way. A browser without geolocation, a\n * refused permission, a fix that never arrived indoors — each is ordinary, and\n * the server treats a missing fix as unverifiable rather than as a failure.\n */\nexport const checkInGeoInputSchema = z.object({\n lat: z.number().min(-90).max(90).optional(),\n lng: z.number().min(-180).max(180).optional(),\n /**\n * The browser's own accuracy radius, metres. Bounded rather than trusted: it\n * widens the allowed radius, so an unbounded value would be a way to switch\n * the check off by claiming a very uncertain fix. The server caps the\n * allowance again on its own side.\n */\n accuracyM: z.number().min(0).max(100_000).optional(),\n});\nexport type CheckInGeoInput = z.infer<typeof checkInGeoInputSchema>;\n"]}
1
+ {"version":3,"file":"location.js","sourceRoot":"","sources":["../src/location.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Where a place physically is\n * ============================================\n * The pasted Google Maps link an owner gives for their headquarters and each\n * centre, and the coordinates read out of it.\n *\n * VALIDATION LIVES HERE, RESOLUTION DOES NOT. Deciding whether a string is a\n * Google Maps link is pure and belongs in one place for both repos. FOLLOWING\n * that link — a server-side fetch of a short URL — stays in gymmonk-backend\n * (`shared/lib/resolve-map-link`), because it is I/O and because it is the\n * SSRF-shaped half that must never run in a browser.\n *\n * WHY THE CLIENT'S COORDINATES ARE NEVER TRUSTED\n * ----------------------------------------------\n * The registration form previews the pin as you paste, so the browser already\n * holds `{lat, lng}` and could simply send them. It must not, and no body schema\n * here accepts them. That pin decides whether a member's check-in counts, so\n * taking it from the client would let anyone replaying the request place a gym\n * on top of a member's sofa and have the geofence agree. The URL is evidence;\n * the coordinates are a conclusion, and the server draws it.\n *\n * @module gymmonk-schema/location\n */\n\nimport { z } from 'zod';\n\n/** Hosts whose links are treated as Google maps. */\nconst MAP_HOSTS = new Set([\n 'maps.google.com',\n 'www.google.com',\n 'google.com',\n 'maps.app.goo.gl',\n 'goo.gl',\n 'g.co',\n]);\n\n/** True for a link we are willing to treat as a Google map. */\nexport function isGoogleMapsUrl(raw: string): boolean {\n try {\n const url = new URL(raw.trim());\n if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;\n const host = url.hostname.toLowerCase();\n // `google.co.in`, `google.co.uk` and the rest of the country domains all\n // serve the same /maps paths.\n return MAP_HOSTS.has(host) || /(^|\\.)google\\.[a-z.]{2,6}$/.test(host);\n } catch {\n return false;\n }\n}\n\n/**\n * The pasted link, validated only as far as \"plausibly a Google map\".\n *\n * Deliberately permissive about FORM and strict about HOST. The backend's\n * resolver performs an outbound request for short links, so the host allowlist\n * is a security control; the rest of the URL can be any of the half-dozen shapes\n * Maps emits, and rejecting one because it did not match a pattern would be\n * telling an owner their own share link is wrong.\n *\n * An empty string is accepted and means \"remove the link\", which is why this\n * cannot simply be `.url()`.\n */\nexport const mapLinkUrlSchema = z\n .string()\n .trim()\n .max(2000)\n .refine((v) => v === '' || isGoogleMapsUrl(v), {\n message: 'Paste a Google Maps link (from Share → Copy link)',\n });\n\n/** Mix into any create/update body that describes a physical place. */\nexport const mapLinkInputSchema = z.object({\n mapLinkUrl: mapLinkUrlSchema.optional(),\n});\nexport type MapLinkInput = z.infer<typeof mapLinkInputSchema>;\n\n/**\n * What the server stored for a resolved link.\n *\n * `lat`/`lng` are absent when the link carried no readable pin. The link is\n * still kept: \"Open in Maps\" works from the url alone, and an owner who pasted\n * something should not find the field empty next time they open the form. It\n * simply means proximity stays unenforceable for that gym, which is the truth.\n */\nexport const mapLinkSchema = z.object({\n url: z.string(),\n lat: z.number().optional(),\n lng: z.number().optional(),\n});\nexport type MapLink = z.infer<typeof mapLinkSchema>;\n\n/**\n * A GeoJSON point, in MongoDB's order.\n *\n * `[lng, lat]`, NOT `[lat, lng]`. Reversing them is the classic 2dsphere bug and\n * it does not throw — it silently puts every Indian gym in the Indian Ocean and\n * the geofence then refuses every check-in.\n */\nexport const geoPointSchema = z.object({\n type: z.literal('Point'),\n coordinates: z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]),\n});\nexport type GeoPoint = z.infer<typeof geoPointSchema>;\n\n/**\n * What a place exposes about its position on the wire.\n *\n * Both optional: no gym is required to have a pin, and one that does not simply\n * cannot have attendance verified against it.\n */\nexport const placePinSchema = z.object({\n mapLink: mapLinkSchema.optional(),\n location: geoPointSchema.optional(),\n});\nexport type PlacePin = z.infer<typeof placePinSchema>;\n\n// ─── Timezone ────────────────────────────────────────────────────────────────\n\n/** Every gym is in India until one is not. */\nexport const DEFAULT_TIMEZONE = 'Asia/Kolkata';\n\n/**\n * Is this a timezone the runtime actually knows?\n *\n * Validated rather than trusted, because an unknown identifier does not degrade\n * gracefully — `Intl.DateTimeFormat` THROWS on one. A bad value stored on a\n * centre would take down every attendance write for that gym, at the moment a\n * member scans, which is the worst possible time to discover a typo.\n *\n * Shared so the server validates before storing and the client can validate\n * before offering a choice.\n */\nexport function isValidTimeZone(value: string): boolean {\n if (!value) return false;\n /**\n * Offset forms are rejected explicitly, and this line is the whole reason the\n * check is not a bare try/catch.\n *\n * ECMA-402 lets `Intl.DateTimeFormat` accept an OFFSET as a time zone, so\n * `\"+05:30\"` constructs perfectly happily. It is still not a timezone: an\n * offset cannot say when the clocks change, so storing one gives a gym a day\n * boundary that is right until DST and silently wrong afterwards — in exactly\n * the zones a global rollout would add.\n */\n if (/^[+-]\\d{2}:?\\d{2}$/.test(value.trim())) return false;\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * An IANA timezone identifier — `Asia/Kolkata`, `America/New_York`.\n *\n * A NAME, never a UTC offset. An offset is a property of an instant rather than\n * of a place: it moves with DST, so a stored `+05:30` is already wrong for half\n * the year anywhere that observes it, and cannot answer \"when does this gym's\n * day start\" in a month's time.\n */\nexport const timezoneSchema = z\n .string()\n .trim()\n .refine(isValidTimeZone, { message: 'Unknown timezone' })\n .default(DEFAULT_TIMEZONE);\n\n// ─── Check-in position ───────────────────────────────────────────────────────\n\n/**\n * The position a check-in request carries: where the scanning device thought it\n * was, and how sure it was.\n *\n * WHY ON THE REQUEST, AND NOT READ FROM THE USER'S STORED FIX\n * The app records a member's location trail separately, and reading the latest\n * entry here would be wrong: that write happens BESIDE the scan, not before it,\n * so at the moment check-in reaches the server the trail may hold a fix from an\n * hour ago, or nothing. Attendance would then be verified against where the\n * member happened to be earlier, which is evidence of nothing.\n *\n * ALL THREE ARE OPTIONAL and stay that way. A browser without geolocation, a\n * refused permission, a fix that never arrived indoors — each is ordinary, and\n * the server treats a missing fix as unverifiable rather than as a failure.\n */\nexport const checkInGeoInputSchema = z.object({\n lat: z.number().min(-90).max(90).optional(),\n lng: z.number().min(-180).max(180).optional(),\n /**\n * The browser's own accuracy radius, metres. Bounded rather than trusted: it\n * widens the allowed radius, so an unbounded value would be a way to switch\n * the check off by claiming a very uncertain fix. The server caps the\n * allowance again on its own side.\n */\n accuracyM: z.number().min(0).max(100_000).optional(),\n});\nexport type CheckInGeoInput = z.infer<typeof checkInGeoInputSchema>;\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gymmonk-schema",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "Shared Zod schemas, enums and domain types for GymMonk (fitness SaaS) — single source of truth (SSOT) consumed by gymmonk-backend and gymmonk-web-client.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",