Compare commits
25 Commits
NewApproach
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d3a98e6ce | |||
| e6777003a2 | |||
| e89f60f8a5 | |||
| 135087e096 | |||
| 7d8fd9f251 | |||
| 7b997dc354 | |||
| d8d53ece93 | |||
| c0d32d7658 | |||
| 5a4a3c51e9 | |||
| 1fdc87f7f4 | |||
| 3ff23cd2bd | |||
| 05f8e2e3b6 | |||
| bd7842799c | |||
| 0b76a4b119 | |||
| 42ff3e8f33 | |||
| 61dbbc457a | |||
| 3cc466c12d | |||
| 1322ace853 | |||
| 00b944c388 | |||
| 9e28d86610 | |||
| 3ed6c888ee | |||
| 09fc949a06 | |||
| d77fa4b817 | |||
| eed8552282 | |||
| 319289cfac |
@@ -1,187 +0,0 @@
|
||||
# Session Changelog - December 11-12, 2025
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
### 1. Fixed Decimal Number Sorting
|
||||
- **Issue**: Job numbers with decimals (e.g., 3887.2) were sorting incorrectly, appearing before higher whole numbers (e.g., 4735)
|
||||
- **Solution**: Replaced parseInt with parseFloat and used regex `/[0-9]+\.?[0-9]*/` to correctly extract and compare decimal job numbers
|
||||
- **File**: Frontend/index.html
|
||||
- **Function**: `jobNumberValue()`
|
||||
|
||||
### 2. Removed Premature Initial Render
|
||||
- **Issue**: Cards were flashing with incorrect order during initial page load, then updating after full load completed
|
||||
- **Solution**: Removed the `initialRendered` flag and early `applyFiltersAndRender()` call during the fetch loop
|
||||
- **Result**: Cards no longer render until data is properly sorted and ready
|
||||
|
||||
### 3. Implemented Server-Side Sorting
|
||||
- **Change**: Added `&sort=-Job_Number` parameter to PocketBase API query
|
||||
- **Benefit**: Data arrives pre-sorted in descending order, eliminating client-side sort overhead
|
||||
- **File**: Frontend/index.html
|
||||
- **Function**: `fetchAllJobs()`
|
||||
|
||||
### 4. Implemented Progressive Loading (2-Phase)
|
||||
- **Phase 1**: First 40 cards display immediately after first batch loads (~50 records from perPage=50)
|
||||
- **Phase 2**: Remaining cards load in background and full list renders when complete
|
||||
- **Benefit**: Users see meaningful content within 1-2 seconds instead of waiting for full dataset
|
||||
- **File**: Frontend/index.html
|
||||
- **Functions**:
|
||||
- `fetchAllJobs()` - triggers render at 40 cards
|
||||
- `renderInitialBatch()` - renders first 40 cards immediately
|
||||
- `applyFiltersAndRender()` - renders complete sorted list after all data loaded
|
||||
|
||||
### 5. Expanded Cards Viewing Area
|
||||
- **Change**: Converted layout to flexbox with `height:100vh` on body
|
||||
- **Result**: Cards area now extends to bottom of screen, leaving only version footer visible
|
||||
- **Improvements**:
|
||||
- Better use of screen real estate
|
||||
- More cards visible without scrolling
|
||||
- Cleaner full-screen experience
|
||||
- **Files Modified**: Frontend/index.html
|
||||
- **CSS Changes**:
|
||||
- `body`: Added `height:100vh`, `display:flex`, `flex-direction:column`, `overflow:hidden`
|
||||
- `.wrap`: Changed to `display:flex`, `flex-direction:column`, `flex:1`, `overflow:hidden`, removed margin
|
||||
- `#results`: Changed `max-height:56vh` to `flex:1` for dynamic expansion
|
||||
|
||||
## Performance Improvements
|
||||
- **Perceived Load Time**: ~1-2 seconds for first 40 cards (was waiting for full load)
|
||||
- **Search/Filter Responsiveness**: Now uses server-side sort, reducing client-side computation
|
||||
- **Memory**: Progressive loading means full dataset isn't necessarily all rendered at once
|
||||
|
||||
## Files Modified
|
||||
- Frontend/index.html (primary changes)
|
||||
- No backend or configuration changes required
|
||||
|
||||
## Testing Notes
|
||||
- Decimal job numbers (e.g., 3887.2, 4735) now sort correctly in descending order
|
||||
- Initial page load shows first 40 cards immediately
|
||||
- Remaining pages load in background with progress bar indication
|
||||
- Full list renders with all filters/search applied once complete
|
||||
|
||||
---
|
||||
|
||||
## December 12, 2025 - UI/UX Refinements
|
||||
|
||||
### 1. Show Filters Button Width Reduction
|
||||
- **Change**: Added `max-width:110px` to `#filterToggle` to constrain button width
|
||||
- **Benefit**: Cleaner, more compact home screen layout
|
||||
- **File**: Frontend/index.html
|
||||
|
||||
### 2. Job Folder Link Conversion to Button (Modal)
|
||||
- **Previous**: Text-based hyperlink with label "Job Folder Link" and "No Link Available" placeholder
|
||||
- **New**: Two-state button in job detail modal
|
||||
- **Disabled State**: Grey button (`#4b5563`), disabled, text = "No Folder Available"
|
||||
- **Active State**: Blue button matching accent color (`#0d6efd`), clickable, text = "Show Job Folder"
|
||||
- **Behavior**: Clicking active button opens job folder URL in new tab with noopener/noreferrer
|
||||
- **File**: Frontend/index.html
|
||||
- **CSS**: Added `#jobFolderBtn` styling with state classes
|
||||
- **JavaScript**: Updated `openDetail()` function to configure button state
|
||||
|
||||
### 3. Job Folder Button Typography
|
||||
- **Font Size**: Increased to 17px (was 15px)
|
||||
- **Font Weight**: Changed to bold (700)
|
||||
- **Result**: Better visual prominence matching modal design
|
||||
|
||||
### 4. Notes Section Color Scheme
|
||||
- **Border Color**: Changed from light (`#e6e9ef`) to dark grey (`#4b5563`) for both `#jobNotes` and `#newNote`
|
||||
- **Text Color**: `#jobNotes` now displays text in dark grey (`#4b5563`) for consistency
|
||||
- **Design Goal**: Unified dark grey accent color matching disabled button background
|
||||
- **File**: Frontend/index.html
|
||||
|
||||
### 5. Notes Section Heading
|
||||
- **Change**: Added blue accent color (`color:var(--accent)`) to Notes h3 heading
|
||||
- **Result**: Matches "Job Detail" h3 styling for visual consistency
|
||||
- **File**: Frontend/index.html
|
||||
|
||||
### 6. Modal Height Expansion
|
||||
- **Previous**: `max-height:84vh`
|
||||
- **Updated**: `max-height:96vh`
|
||||
- **Additional Space**: ~60px more at bottom for better content visibility
|
||||
- **Auto-Scroll**: Modal now automatically scrolls to top when opened
|
||||
- **Implementation**: Added `document.getElementById('detailBox').scrollTop = 0;` in `openDetail()` function
|
||||
- **File**: Frontend/index.html
|
||||
|
||||
## UI/UX Summary
|
||||
All changes focused on improving the job detail modal design:
|
||||
- Converted job folder link from text to button interface for better UX
|
||||
- Implemented cohesive color scheme using dark grey (`#4b5563`) as accent
|
||||
- Increased modal height to 96vh for better content visibility
|
||||
- Auto-scroll to top ensures users always see details from the beginning
|
||||
- Typography enhancements (larger, bolder button text) improve readability
|
||||
|
||||
## Files Modified (December 12)
|
||||
- Frontend/index.html (all UI/UX changes)
|
||||
|
||||
---
|
||||
|
||||
## December 12, 2025 - Tailwind CSS Migration & Major UI Redesign
|
||||
|
||||
### 1. Tailwind CSS Migration
|
||||
- **signin.html**: Converted entire file from custom CSS to Tailwind CSS utility classes
|
||||
- Removed ~116 lines of custom CSS
|
||||
- All styles now use Tailwind utility classes
|
||||
- Maintained exact visual appearance and functionality
|
||||
- Updated JavaScript to use `classList.add/remove('hidden')` instead of `style.display`
|
||||
- **index.html**: Converted entire file from custom CSS to Tailwind CSS utility classes
|
||||
- Removed ~82 lines of custom CSS
|
||||
- All HTML elements converted to Tailwind utility classes
|
||||
- Dynamically created elements (cards, table cells) updated to use Tailwind classes
|
||||
- JavaScript updated to use Tailwind class manipulation
|
||||
- **Benefits**:
|
||||
- ~70-80% reduction in CSS code
|
||||
- Styles co-located with HTML for better maintainability
|
||||
- Consistent design system
|
||||
- Better responsive design with Tailwind breakpoints
|
||||
|
||||
### 2. Responsive Layout Improvements
|
||||
- **Change**: Added max-width constraint to main container
|
||||
- Desktop: `max-w-2xl` (800px) centered layout
|
||||
- Mobile: Full width on screens ≤800px
|
||||
- **Result**: Content no longer stretches full width on large screens, better readability
|
||||
- **File**: Frontend/index.html
|
||||
|
||||
### 3. Job Details Modal Redesign
|
||||
- **Tab System**: Implemented INFO and NOTES tabs
|
||||
- Tab bar moved to bottom of modal
|
||||
- Blue heading bar for INFO tab
|
||||
- Orange heading bar for NOTES tab
|
||||
- Headings fill top of card including rounded corners
|
||||
- **Back Button**: Added back button to left of tabs for easy modal dismissal
|
||||
- **Visual Hierarchy**:
|
||||
- All field labels now bold (`font-bold`)
|
||||
- Cleaner table design with better spacing
|
||||
- Job Number/Company Client displayed side by side
|
||||
- Contact Person/Phone Number displayed side by side
|
||||
- **Modal Height**: Constrained to `h-[90vh] max-h-[926px]` (iPhone Pro Max size) to prevent full-height on desktop browsers
|
||||
- **File**: Frontend/index.html
|
||||
|
||||
### 4. Interactive Address & Phone Features
|
||||
- **Address**: Made clickable with map integration
|
||||
- Apple Maps icon opens address in Apple Maps
|
||||
- Google Maps icon opens address in Google Maps
|
||||
- Icons displayed inline with address text
|
||||
- **Phone Number**: Made clickable with `tel:` protocol
|
||||
- Opens phone dialer on iOS devices
|
||||
- Properly formatted phone numbers for dialing
|
||||
- **File**: Frontend/index.html
|
||||
|
||||
### 5. UI Polish
|
||||
- **Filter Toggle Button**: Increased width to `min-w-[140px]` to prevent text wrapping
|
||||
- **Job Detail Heading**: Removed generic "Job Detail" heading, now displays Job Full Name dynamically
|
||||
- **Table Redesign**: Converted from table to cleaner div-based layout with better typography
|
||||
- **Date Formatting**: Start dates now display in readable format (e.g., "July 28, 2026")
|
||||
- **Empty Field Handling**: Tax Exempt field hidden when empty
|
||||
|
||||
### 6. Version Update
|
||||
- Updated version from `v1.0.0-beta2` to `v1.0.0-beta3`
|
||||
|
||||
## Technical Improvements
|
||||
- **Code Maintainability**: Tailwind CSS makes styles easier to modify and maintain
|
||||
- **Performance**: Reduced CSS file size, faster rendering
|
||||
- **Responsive Design**: Better mobile/desktop experience with Tailwind breakpoints
|
||||
- **Accessibility**: Maintained all ARIA labels and semantic HTML
|
||||
|
||||
## Files Modified
|
||||
- Frontend/signin.html (complete Tailwind migration)
|
||||
- Frontend/index.html (complete Tailwind migration + major UI redesign)
|
||||
- Images/apple.svg (used for Apple Maps integration)
|
||||
- Images/google.svg (used for Google Maps integration)
|
||||
@@ -1,73 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright 2025 aewing
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Mode1 - Auth Module Test Suite
|
||||
|
||||
Self-contained test environment for the auth-module. This directory includes its own copy of auth-module and all dependencies.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
Mode1/
|
||||
├── auth-module/ # Local copy of auth-module (not linked to root)
|
||||
├── backend/ # Backend server with auth endpoints
|
||||
│ └── server.ts # Hono app with Graph token and PB validation APIs
|
||||
├── frontend/ # Frontend test interface
|
||||
│ └── index.html # Interactive test dashboard
|
||||
├── logs/ # Application logs
|
||||
├── package.json # Dependencies (auth module + Hono + PocketBase)
|
||||
└── bun.lock # Locked dependencies
|
||||
```
|
||||
|
||||
## Features Tested
|
||||
|
||||
✓ **Frontend Auth (PocketBaseAuth)**
|
||||
- OAuth2 login initialization
|
||||
- Auth state retrieval
|
||||
- Token refresh
|
||||
- Logout
|
||||
|
||||
✓ **Backend Auth (GraphTokenManager)**
|
||||
- Microsoft Graph token acquisition
|
||||
- Token caching (with 60s expiration buffer)
|
||||
- Cache validity checking
|
||||
- Cache refresh
|
||||
|
||||
✓ **PocketBase Validation (PocketBaseValidator)**
|
||||
- User token validation
|
||||
- User record retrieval
|
||||
|
||||
## Running Mode1
|
||||
|
||||
### Via Orchestrator (recommended)
|
||||
|
||||
```bash
|
||||
# Switch to Mode1
|
||||
curl -X POST http://localhost:3006/switch/Mode1
|
||||
|
||||
# Check status
|
||||
curl http://localhost:3006/status
|
||||
```
|
||||
|
||||
### Direct
|
||||
|
||||
```bash
|
||||
# Install dependencies (already done)
|
||||
bun install
|
||||
|
||||
# Start server
|
||||
bun run backend/server.ts
|
||||
|
||||
# Access dashboard
|
||||
open http://localhost:3005
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Configuration
|
||||
- `GET /api/auth/config` - Get frontend configuration
|
||||
|
||||
### Graph Token Management
|
||||
- `GET /api/auth/graph/token` - Get current Graph token (with expiration)
|
||||
- `POST /api/auth/refresh-graph` - Force refresh Graph token
|
||||
|
||||
### PocketBase Validation
|
||||
- `POST /api/auth/validate-pb-token` - Validate a PocketBase token
|
||||
|
||||
### Health
|
||||
- `GET /health` - Health check
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Uses `/home/admin/secrets/.env` via auth-module:
|
||||
- `CLIENT_ID` - Microsoft application ID
|
||||
- `TENANT_ID` - Azure tenant ID
|
||||
- `CLIENT_SECRET` - Microsoft client secret
|
||||
- `PB_URL` - PocketBase frontend URL
|
||||
- `PB_DB` - PocketBase backend URL
|
||||
|
||||
## Dependencies
|
||||
|
||||
All dependencies are installed locally in `node_modules/`:
|
||||
- `hono` - Web framework
|
||||
- `pocketbase` - PocketBase client
|
||||
- `@azure/msal-node` - Microsoft authentication
|
||||
- `dotenv` - Environment variable loading
|
||||
|
||||
## Notes
|
||||
|
||||
- This Mode1 is **completely self-contained** with its own auth-module copy
|
||||
- No dependencies on root auth-module
|
||||
- Safe to modify and test independently
|
||||
- All secrets come from `/home/admin/secrets/.env`
|
||||
@@ -0,0 +1,248 @@
|
||||
# Auth Module
|
||||
|
||||
Standalone authentication module for PocketBase OAuth2 + Microsoft Graph integration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Frontend**: PocketBase OAuth2 authentication with Microsoft provider
|
||||
- **Backend**: Microsoft Graph token management with automatic caching
|
||||
- **No dependencies on project-specific code** - Use in any project
|
||||
|
||||
## Installation
|
||||
|
||||
Copy the `auth-module` folder to your project:
|
||||
|
||||
```bash
|
||||
cp -r auth-module /path/to/your/project/
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
bun add pocketbase @azure/msal-node
|
||||
```
|
||||
|
||||
## Frontend Usage
|
||||
|
||||
### Basic Setup
|
||||
|
||||
**Important**: The frontend is browser-side code. It must receive `pbUrl` from the consuming application (not from the backend).
|
||||
|
||||
```typescript
|
||||
import { PocketBaseAuth } from './auth-module/frontend';
|
||||
|
||||
// In your project, load PB_URL from environment/config and pass it to frontend
|
||||
const auth = new PocketBaseAuth({
|
||||
pbUrl: process.env.PB_URL, // Required - must be provided by consuming application
|
||||
collection: 'Users',
|
||||
provider: 'microsoft',
|
||||
loginContainerId: 'loginContainer',
|
||||
userDisplayNameId: 'userDisplayName',
|
||||
userEmailId: 'userEmailValue',
|
||||
loginBtnId: 'loginBtn',
|
||||
loginErrorId: 'loginError',
|
||||
});
|
||||
```
|
||||
|
||||
### HTML Required
|
||||
|
||||
```html
|
||||
<!-- Login container (shown when not authenticated) -->
|
||||
<div id="loginContainer" class="hidden">
|
||||
<button id="loginBtn">Login with Microsoft</button>
|
||||
<div id="loginError" class="hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- User display (shown when authenticated) -->
|
||||
<div id="userDisplayName"></div>
|
||||
<div id="userEmailValue"></div>
|
||||
```
|
||||
|
||||
### Event Callbacks
|
||||
|
||||
```typescript
|
||||
const auth = new PocketBaseAuth(config, {
|
||||
onAuthSuccess: (state) => {
|
||||
console.log('Logged in:', state.user.email);
|
||||
// Initialize other systems here
|
||||
},
|
||||
onAuthFailure: (error) => {
|
||||
console.error('Login failed:', error);
|
||||
},
|
||||
onTokenUpdate: (state) => {
|
||||
console.log('Token refreshed');
|
||||
},
|
||||
onUiUpdate: (state) => {
|
||||
console.log('UI updated');
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
```typescript
|
||||
// Get current auth state
|
||||
const state = auth.getAuthState();
|
||||
console.log(state.isAuthenticated, state.user);
|
||||
|
||||
// Check token status
|
||||
const status = await auth.checkTokenStatus();
|
||||
|
||||
// Logout
|
||||
auth.logout();
|
||||
|
||||
// Get raw PocketBase instance if needed
|
||||
const pb = auth.getPocketBase();
|
||||
```
|
||||
|
||||
## Backend Usage
|
||||
|
||||
### Setup (server.ts)
|
||||
|
||||
```typescript
|
||||
import { GraphTokenManager, PocketBaseValidator, BackendAuth } from './auth-module/backend';
|
||||
|
||||
// Option 1: Use individual managers
|
||||
const graphMgr = new GraphTokenManager({
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
});
|
||||
|
||||
const pbValidator = new PocketBaseValidator(process.env.PB_DB);
|
||||
|
||||
// Option 2: Use combined manager
|
||||
const backendAuth = new BackendAuth({
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
}, process.env.PB_DB);
|
||||
```
|
||||
|
||||
### Get Graph Token
|
||||
|
||||
```typescript
|
||||
// Get token string
|
||||
const token = await graphMgr.getToken();
|
||||
|
||||
// Get token with expiration
|
||||
const { token, expiresOnISO } = await graphMgr.getTokenWithExpiry();
|
||||
|
||||
// Check if cached and valid
|
||||
if (graphMgr.isTokenValid()) {
|
||||
console.log('Using cached token');
|
||||
}
|
||||
|
||||
// Clear cache (force refresh)
|
||||
graphMgr.clearCache();
|
||||
```
|
||||
|
||||
### Validate PocketBase Token
|
||||
|
||||
```typescript
|
||||
// Validate token
|
||||
const isValid = await pbValidator.validateUserToken(token);
|
||||
|
||||
// Get user record
|
||||
const user = await pbValidator.getUserRecord(token);
|
||||
|
||||
// Get PocketBase instance
|
||||
const pb = pbValidator.getPocketBase();
|
||||
```
|
||||
|
||||
### Endpoint Example
|
||||
|
||||
```typescript
|
||||
app.get('/api/graph/status', async (c) => {
|
||||
try {
|
||||
const { token, expiresOnISO } = await graphMgr.getTokenWithExpiry();
|
||||
return c.json({
|
||||
active: true,
|
||||
expiresOnISO,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ active: false, error: error.message },
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/submit', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const pbToken = body.pbToken;
|
||||
|
||||
if (!pbToken) {
|
||||
return c.json({ error: 'Missing pbToken' }, 401);
|
||||
}
|
||||
|
||||
const isValid = await pbValidator.validateUserToken(pbToken);
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid token' }, 401);
|
||||
}
|
||||
|
||||
// Token is valid, proceed with business logic
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required in `.env` file:
|
||||
|
||||
```env
|
||||
CLIENT_ID=your-microsoft-app-id
|
||||
TENANT_ID=your-azure-tenant-id
|
||||
CLIENT_SECRET=your-microsoft-client-secret
|
||||
PB_DB=https://your-pocketbase-instance.com
|
||||
PB_URL=https://your-pocketbase-instance.com
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Frontend Config Options
|
||||
|
||||
```typescript
|
||||
interface AuthConfig {
|
||||
pbUrl: string; // PocketBase URL (required)
|
||||
collection?: string; // Auth collection (default: Users)
|
||||
provider?: string; // OAuth provider (default: microsoft)
|
||||
loginContainerId?: string; // ID of login container element
|
||||
userDisplayNameId?: string; // ID of user name display element
|
||||
userEmailId?: string; // ID of user email display element
|
||||
loginBtnId?: string; // ID of login button element
|
||||
loginErrorId?: string; // ID of error message element
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Config Options
|
||||
|
||||
```typescript
|
||||
interface BackendAuthConfig {
|
||||
clientId?: string; // Microsoft app ID (or CLIENT_ID env var)
|
||||
tenantId?: string; // Azure tenant ID (or TENANT_ID env var)
|
||||
clientSecret?: string; // Client secret (or CLIENT_SECRET env var)
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
The module exports TypeScript types for type safety:
|
||||
|
||||
```typescript
|
||||
import { AuthState, AuthConfig, GraphTokenCache } from './auth-module/types';
|
||||
|
||||
const state: AuthState = auth.getAuthState();
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Frontend** manages user authentication only
|
||||
- **Backend** manages Graph API tokens (never exposed to client)
|
||||
- Tokens are cached in-memory on backend with automatic refresh
|
||||
- Module does not include project-specific features (alerts, etc.)
|
||||
- Each project can implement its own business logic on top
|
||||
|
||||
## License
|
||||
|
||||
Same as parent project
|
||||
@@ -0,0 +1,204 @@
|
||||
import { config } from 'dotenv';
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { GraphTokenCache, BackendAuthConfig } from './types';
|
||||
|
||||
// Load environment variables from shared secrets directory
|
||||
config({ path: '/home/admin/secrets/.env' });
|
||||
|
||||
/**
|
||||
* Configuration Manager
|
||||
* Exposes frontend-safe configuration loaded from environment
|
||||
*/
|
||||
export class AuthConfigManager {
|
||||
/**
|
||||
* Get frontend configuration (safe to expose to browser)
|
||||
*/
|
||||
static getFrontendConfig() {
|
||||
return {
|
||||
pbUrl: process.env.PB_URL!,
|
||||
provider: 'microsoft',
|
||||
collection: 'Users',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all backend secrets (never expose to frontend)
|
||||
*/
|
||||
static getBackendConfig() {
|
||||
return {
|
||||
clientId: process.env.CLIENT_ID!,
|
||||
tenantId: process.env.TENANT_ID!,
|
||||
clientSecret: process.env.CLIENT_SECRET!,
|
||||
pbDb: process.env.PB_DB!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft Graph Token Management (Backend)
|
||||
* Handles token acquisition and caching for backend Graph API calls
|
||||
*/
|
||||
export class GraphTokenManager {
|
||||
private cca: ConfidentialClientApplication;
|
||||
private cache: GraphTokenCache | null = null;
|
||||
private config: Required<BackendAuthConfig>;
|
||||
|
||||
constructor(config: BackendAuthConfig) {
|
||||
this.config = {
|
||||
clientId: config.clientId || process.env.CLIENT_ID || '',
|
||||
tenantId: config.tenantId || process.env.TENANT_ID || '',
|
||||
clientSecret: config.clientSecret || process.env.CLIENT_SECRET || '',
|
||||
};
|
||||
|
||||
this.cca = new ConfidentialClientApplication({
|
||||
auth: {
|
||||
clientId: this.config.clientId,
|
||||
authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
|
||||
clientSecret: this.config.clientSecret,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Graph token (from cache or acquire new)
|
||||
*/
|
||||
async getToken(): Promise<string> {
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache validity (with 60s buffer for expiration)
|
||||
if (this.cache && this.cache.expiresOn - 60000 > now) {
|
||||
return this.cache.token;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.cca.acquireTokenByClientCredential({
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
});
|
||||
|
||||
if (!result?.accessToken) {
|
||||
throw new Error('Failed to acquire Graph token');
|
||||
}
|
||||
|
||||
const expiresOn = result.expiresOn
|
||||
? new Date(result.expiresOn).getTime()
|
||||
: now + 55 * 60 * 1000; // Default 55 minutes
|
||||
|
||||
this.cache = {
|
||||
token: result.accessToken,
|
||||
expiresOn,
|
||||
};
|
||||
|
||||
return result.accessToken;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to acquire Graph token: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token with expiration info
|
||||
*/
|
||||
async getTokenWithExpiry(): Promise<{ token: string; expiresOnISO: string }> {
|
||||
const token = await this.getToken();
|
||||
const expiresOn = this.cache?.expiresOn || Date.now();
|
||||
return {
|
||||
token,
|
||||
expiresOnISO: new Date(expiresOn).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is cached and valid
|
||||
*/
|
||||
isTokenValid(): boolean {
|
||||
if (!this.cache) return false;
|
||||
const now = Date.now();
|
||||
return this.cache.expiresOn - 60000 > now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache (force refresh on next call)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PocketBase Token Validation (Backend)
|
||||
* Validates and uses user PocketBase tokens
|
||||
*/
|
||||
export class PocketBaseValidator {
|
||||
private pb: PocketBase;
|
||||
|
||||
constructor(pbUrl?: string) {
|
||||
this.pb = new PocketBase(pbUrl || process.env.PB_DB!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user token and validate it
|
||||
*/
|
||||
async validateUserToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
this.pb.authStore.save(token, null);
|
||||
await this.pb.collection('Users').authRefresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Token validation failed:', error instanceof Error ? error.message : error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user record from token
|
||||
*/
|
||||
async getUserRecord(token: string): Promise<any> {
|
||||
try {
|
||||
this.pb.authStore.save(token, null);
|
||||
const record = this.pb.authStore.record || this.pb.authStore.model;
|
||||
return record;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user record:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PocketBase instance
|
||||
*/
|
||||
getPocketBase(): PocketBase {
|
||||
return this.pb;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined backend auth manager
|
||||
*/
|
||||
export class BackendAuth {
|
||||
graphTokenManager: GraphTokenManager;
|
||||
pbValidator: PocketBaseValidator;
|
||||
|
||||
constructor(config: BackendAuthConfig, pbUrl?: string) {
|
||||
this.graphTokenManager = new GraphTokenManager(config);
|
||||
this.pbValidator = new PocketBaseValidator(pbUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate PocketBase token in requests
|
||||
* Usage: app.use('/*', (c, next) => backendAuth.validateTokenMiddleware(c, next))
|
||||
*/
|
||||
async validateTokenMiddleware(c: any, next: any): Promise<any> {
|
||||
const token = c.req.header('Authorization')?.replace('Bearer ', '') ||
|
||||
(await c.req.json().catch(() => ({})))?.pbToken;
|
||||
|
||||
if (token) {
|
||||
const isValid = await this.pbValidator.validateUserToken(token);
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid token' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import { AuthConfig, AuthState, AuthCallbacks } from './types';
|
||||
|
||||
/**
|
||||
* PocketBase OAuth2 Frontend Module
|
||||
* Handles user authentication and token management
|
||||
*/
|
||||
export class PocketBaseAuth {
|
||||
private pb: PocketBase;
|
||||
private config: Required<AuthConfig>;
|
||||
private callbacks: AuthCallbacks;
|
||||
private state: AuthState;
|
||||
|
||||
constructor(config: AuthConfig, callbacks?: Partial<AuthCallbacks>) {
|
||||
this.config = {
|
||||
pbUrl: config.pbUrl!,
|
||||
collection: config.collection || 'Users',
|
||||
provider: config.provider || 'microsoft',
|
||||
loginContainerId: config.loginContainerId || 'loginContainer',
|
||||
userDisplayNameId: config.userDisplayNameId || 'userDisplayName',
|
||||
userEmailId: config.userEmailId || 'userEmailValue',
|
||||
loginBtnId: config.loginBtnId || 'loginBtn',
|
||||
loginErrorId: config.loginErrorId || 'loginError',
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
onAuthSuccess: callbacks?.onAuthSuccess || (() => {}),
|
||||
onAuthFailure: callbacks?.onAuthFailure || (() => {}),
|
||||
onTokenUpdate: callbacks?.onTokenUpdate || (() => {}),
|
||||
onUiUpdate: callbacks?.onUiUpdate || (() => {}),
|
||||
};
|
||||
|
||||
this.pb = new PocketBase(this.config.pbUrl);
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth module
|
||||
*/
|
||||
private init(): void {
|
||||
this.updateAuthUI();
|
||||
if (this.pb.authStore.isValid && this.pb.authStore.token) {
|
||||
this.ensureUserLogged();
|
||||
}
|
||||
this.setupLoginButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup login button click handler
|
||||
*/
|
||||
private setupLoginButton(): void {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId);
|
||||
if (!loginBtn) {
|
||||
console.warn(`Login button with id "${this.config.loginBtnId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
loginBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.handleLogin();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login button click
|
||||
*/
|
||||
private async handleLogin(): Promise<void> {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
|
||||
const loginError = document.getElementById(this.config.loginErrorId) as HTMLElement;
|
||||
|
||||
if (!loginBtn || !loginError) return;
|
||||
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Checking session...';
|
||||
loginError.classList.add('hidden');
|
||||
|
||||
try {
|
||||
// Try to use existing token first
|
||||
const hadToken = await this.ensureUserLogged();
|
||||
if (hadToken) {
|
||||
this.resetLoginButton();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise perform OAuth
|
||||
loginBtn.textContent = 'Logging in...';
|
||||
const authData = await this.pb.collection(this.config.collection).authWithOAuth2({
|
||||
provider: this.config.provider,
|
||||
});
|
||||
|
||||
this.updateState(authData);
|
||||
this.updateAuthUI();
|
||||
this.callbacks.onAuthSuccess?.(this.state);
|
||||
|
||||
console.log('✓ Logged in with', this.config.provider);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Login failed:', message);
|
||||
loginError.textContent = `Login failed: ${message}`;
|
||||
loginError.classList.remove('hidden');
|
||||
this.callbacks.onAuthFailure?.(error);
|
||||
} finally {
|
||||
this.resetLoginButton();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user is logged in (check existing token)
|
||||
*/
|
||||
async ensureUserLogged(): Promise<boolean> {
|
||||
if (!this.pb.authStore.isValid || !this.pb.authStore.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const refresh = await this.pb.collection(this.config.collection).authRefresh();
|
||||
this.updateState(refresh);
|
||||
this.updateAuthUI();
|
||||
this.callbacks.onTokenUpdate?.(this.state);
|
||||
console.log('✓ Token refreshed');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Existing token invalid, clearing auth');
|
||||
this.pb.authStore.clear();
|
||||
this.updateState(null);
|
||||
this.updateAuthUI();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update internal auth state
|
||||
*/
|
||||
private updateState(data: any): void {
|
||||
if (!data) {
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const record = data.record || this.pb.authStore.record || this.pb.authStore.model;
|
||||
const meta = data.meta || {};
|
||||
const model = this.pb.authStore.model;
|
||||
|
||||
this.state = {
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
id: record?.id || model?.id || 'unknown',
|
||||
name: record?.name || model?.name || meta?.name || record?.email || model?.email || 'Unknown User',
|
||||
email: record?.email || model?.email || meta?.email || '(no email)',
|
||||
},
|
||||
token: this.pb.authStore.token,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI based on auth state
|
||||
*/
|
||||
updateAuthUI(): void {
|
||||
const loginContainer = document.getElementById(this.config.loginContainerId);
|
||||
const userDisplayName = document.getElementById(this.config.userDisplayNameId);
|
||||
const userEmail = document.getElementById(this.config.userEmailId);
|
||||
|
||||
if (!loginContainer) return;
|
||||
|
||||
if (this.pb.authStore.isValid) {
|
||||
loginContainer.classList.add('hidden');
|
||||
if (this.state.user) {
|
||||
if (userDisplayName) userDisplayName.textContent = this.state.user.name;
|
||||
if (userEmail) userEmail.textContent = this.state.user.email;
|
||||
}
|
||||
} else {
|
||||
loginContainer.classList.remove('hidden');
|
||||
const loginError = document.getElementById(this.config.loginErrorId);
|
||||
if (loginError) loginError.classList.add('hidden');
|
||||
}
|
||||
|
||||
this.callbacks.onUiUpdate?.(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check token status (for verification/display)
|
||||
*/
|
||||
async checkTokenStatus(): Promise<{ pbToken: boolean; tokenExpiry?: string }> {
|
||||
const pbToken = !!this.pb.authStore.token;
|
||||
return { pbToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auth state
|
||||
*/
|
||||
getAuthState(): AuthState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PocketBase instance (for direct usage if needed)
|
||||
*/
|
||||
getPocketBase(): PocketBase {
|
||||
return this.pb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
logout(): void {
|
||||
this.pb.authStore.clear();
|
||||
this.updateState(null);
|
||||
this.updateAuthUI();
|
||||
console.log('✓ Logged out');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset login button to initial state
|
||||
*/
|
||||
private resetLoginButton(): void {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
|
||||
if (loginBtn) {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Login with Microsoft';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick init function - fetches config from backend
|
||||
*/
|
||||
export async function initPocketBaseAuth(
|
||||
backendUrl: string,
|
||||
callbacks?: Partial<AuthCallbacks>
|
||||
): Promise<PocketBaseAuth> {
|
||||
// Fetch frontend config from backend
|
||||
const response = await fetch(`${backendUrl}/api/auth/config`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch auth configuration from backend');
|
||||
}
|
||||
const config = await response.json();
|
||||
|
||||
const auth = new PocketBaseAuth(config, callbacks);
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Frontend Auth Configuration
|
||||
*/
|
||||
export interface AuthConfig {
|
||||
pbUrl: string; // PocketBase URL (required)
|
||||
collection?: string;
|
||||
provider?: string;
|
||||
loginContainerId?: string;
|
||||
userDisplayNameId?: string;
|
||||
userEmailId?: string;
|
||||
loginBtnId?: string;
|
||||
loginErrorId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend configuration provided by backend
|
||||
*/
|
||||
export interface FrontendConfig {
|
||||
pbUrl: string;
|
||||
provider?: string;
|
||||
collection?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend Auth Configuration
|
||||
*/
|
||||
export interface BackendAuthConfig {
|
||||
clientId?: string;
|
||||
tenantId?: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state object
|
||||
*/
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
} | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph token cache object
|
||||
*/
|
||||
export interface GraphTokenCache {
|
||||
token: string;
|
||||
expiresOn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth event callbacks
|
||||
*/
|
||||
export interface AuthCallbacks {
|
||||
onAuthSuccess?: (state: AuthState) => void;
|
||||
onAuthFailure?: (error: any) => void;
|
||||
onTokenUpdate?: (state: AuthState) => void;
|
||||
onUiUpdate?: (state: AuthState) => void;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { cors } from 'hono/cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { AuthConfigManager, BackendAuth } from '../auth-module/backend';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const app = new Hono();
|
||||
const PORT = process.env.PORT || 3005;
|
||||
|
||||
// Initialize auth
|
||||
const backendAuth = new BackendAuth({
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
}, process.env.PB_DB);
|
||||
|
||||
// CORS Configuration - Allow requests from frontend domains
|
||||
app.use('*', cors({
|
||||
origin: ['https://ji-test.ccllc.pro', 'http://localhost:3005', 'http://localhost:5173'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Serve bundled auth module JavaScript
|
||||
app.get('/auth-module.js', async (c) => {
|
||||
const filePath = path.join(__dirname, '../frontend/dist/auth.js');
|
||||
if (existsSync(filePath)) {
|
||||
const content = await readFile(filePath);
|
||||
c.header('Content-Type', 'application/javascript');
|
||||
return c.body(content);
|
||||
}
|
||||
return c.notFound();
|
||||
});
|
||||
|
||||
// Serve frontend static files
|
||||
app.use('/', serveStatic({ root: path.join(__dirname, '../frontend') }));
|
||||
|
||||
// API: Get frontend config
|
||||
app.get('/api/auth/config', (c) => {
|
||||
try {
|
||||
const config = AuthConfigManager.getFrontendConfig();
|
||||
return c.json(config);
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ error: 'Failed to get config', message: error instanceof Error ? error.message : 'Unknown error' },
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// API: Get Graph token status
|
||||
app.get('/api/auth/graph/token', async (c) => {
|
||||
try {
|
||||
const { token, expiresOnISO } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
|
||||
if (!token) {
|
||||
return c.json({ success: false, error: 'Failed to acquire Graph token' }, 500);
|
||||
}
|
||||
|
||||
// Decode JWT to show token details (for verification)
|
||||
const parts = token.split('.');
|
||||
let payload = {};
|
||||
if (parts.length === 3) {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
payload = {
|
||||
aud: decoded.aud,
|
||||
iss: decoded.iss,
|
||||
scp: decoded.scp,
|
||||
app_displayname: decoded.app_displayname,
|
||||
iat: new Date(decoded.iat * 1000).toISOString(),
|
||||
exp: new Date(decoded.exp * 1000).toISOString(),
|
||||
};
|
||||
} catch (e) {
|
||||
// Silent fail - just show truncated token
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
token: token.substring(0, 50) + '...',
|
||||
fullToken: token, // Include full token for testing
|
||||
expiresOn: expiresOnISO,
|
||||
isValid: backendAuth.graphTokenManager.isTokenValid(),
|
||||
tokenDetails: Object.keys(payload).length > 0 ? payload : 'Could not decode',
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to acquire token' },
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// API: Validate PocketBase token
|
||||
app.post('/api/auth/validate-pb-token', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const token = body.pbToken;
|
||||
|
||||
if (!token) {
|
||||
return c.json({ valid: false, error: 'No token provided' }, 400);
|
||||
}
|
||||
|
||||
const isValid = await backendAuth.pbValidator.validateUserToken(token);
|
||||
const user = isValid ? await backendAuth.pbValidator.getUserRecord(token) : null;
|
||||
|
||||
return c.json({
|
||||
valid: isValid,
|
||||
user: user ? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ valid: false, error: error instanceof Error ? error.message : 'Validation failed' },
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// API: Refresh Graph token (test cache)
|
||||
app.post('/api/auth/refresh-graph', async (c) => {
|
||||
try {
|
||||
backendAuth.graphTokenManager.clearCache();
|
||||
const { token, expiresOnISO } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
return c.json({
|
||||
success: true,
|
||||
refreshed: true,
|
||||
expiresOn: expiresOnISO,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Refresh failed' },
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// API: Compare PB and Graph tokens
|
||||
app.post('/api/auth/compare-tokens', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const pbToken = body.pbToken;
|
||||
|
||||
// Get Graph token
|
||||
const { token: graphToken, expiresOnISO: graphExpires } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
|
||||
if (!graphToken) {
|
||||
return c.json({ success: false, error: 'Failed to acquire Graph token' }, 500);
|
||||
}
|
||||
|
||||
// Decode both tokens to compare
|
||||
const decodeJWT = (token: string) => {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
return JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const graphPayload = decodeJWT(graphToken);
|
||||
|
||||
// Validate PB token to get actual user
|
||||
let authenticatedUser = 'Unknown';
|
||||
if (pbToken) {
|
||||
try {
|
||||
const isValid = await backendAuth.pbValidator.validateUserToken(pbToken);
|
||||
if (isValid) {
|
||||
const userRecord = await backendAuth.pbValidator.getUserRecord(pbToken);
|
||||
authenticatedUser = userRecord?.email || userRecord?.id || 'Unknown';
|
||||
}
|
||||
} catch {
|
||||
authenticatedUser = 'Token invalid or expired';
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
comparison: {
|
||||
graph_token: {
|
||||
issuer: graphPayload?.iss || 'N/A',
|
||||
audience: graphPayload?.aud || 'N/A',
|
||||
app: graphPayload?.app_displayname || 'N/A',
|
||||
scopes: graphPayload?.scp || 'N/A',
|
||||
issued_at: graphPayload?.iat ? new Date(graphPayload.iat * 1000).toISOString() : 'N/A',
|
||||
expires_at: graphPayload?.exp ? new Date(graphPayload.exp * 1000).toISOString() : 'N/A',
|
||||
type: 'Microsoft Graph Token (Backend/App-only)',
|
||||
source: '@azure/msal-node',
|
||||
purpose: 'Backend API calls to Microsoft Graph',
|
||||
},
|
||||
pocketbase_token: {
|
||||
note: 'User token from PocketBase OAuth2',
|
||||
type: 'PocketBase User Token (Frontend/User)',
|
||||
source: 'PocketBase OAuth2 flow',
|
||||
purpose: 'User authentication and authorization',
|
||||
authenticated_user: authenticatedUser,
|
||||
},
|
||||
are_different: true,
|
||||
explanation: 'These are completely different tokens from different systems: Graph token is for app-to-app Microsoft API access, PocketBase token is for user authentication in the application.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to compare tokens' },
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
console.log(`Mode1 Auth Test Server running on port ${PORT}`);
|
||||
export default {
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "mode1-auth-test",
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.0.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"pocketbase": "^0.26.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5",
|
||||
"vite": "^7.3.1",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@azure/msal-common": ["@azure/msal-common@16.0.2", "", {}, "sha512-ZJ/UR7lyqIntURrIJCyvScwJFanM9QhJYcJCheB21jZofGKpP9QxWgvADANo7UkresHKzV+6YwoeZYP7P7HvUg=="],
|
||||
|
||||
"@azure/msal-node": ["@azure/msal-node@5.0.2", "", { "dependencies": { "@azure/msal-common": "16.0.2", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-3tHeJghckgpTX98TowJoXOjKGuds0L+FKfeHJtoZFl2xvwE6RF65shZJzMQ5EQZWXzh3sE1i9gE+m3aRMachjA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||
|
||||
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw=="],
|
||||
|
||||
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA=="],
|
||||
|
||||
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w=="],
|
||||
|
||||
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-YaQEAYjBanoOOtpqk/c5GGcfZIyxIIkQ2m1TbHjedRmJNwxzWBhGinSARFkrRIc3F8pRIGAopXKvJ/2rjN1LzQ=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FR+iJt17rfFgYgpxL3M67AUwujOgjw52ZJzB9vElI5jQXNjTyOKf8eH4meSk4vjlYF3h/AjKYd6pmN0OIUlVKQ=="],
|
||||
|
||||
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig=="],
|
||||
|
||||
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w=="],
|
||||
|
||||
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ=="],
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.3", "", { "os": "android", "cpu": "arm" }, "sha512-qyX8+93kK/7R5BEXPC2PjUt0+fS/VO2BVHjEHyIEWiYn88rcRBHmdLgoJjktBltgAf+NY7RfCGB1SoyKS/p9kg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.3", "", { "os": "android", "cpu": "arm64" }, "sha512-6sHrL42bjt5dHQzJ12Q4vMKfN+kUnZ0atHHnv4V0Wd9JMTk7FDzSY35+7qbz3ypQYMBPANbpGK7JpnWNnhGt8g=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ht2SpGIjEl2igJ9AbNpPIKzb1B5goXOcmtD0RFxnwNuMxqkR6AUaaErZz+4o+FKmzxcSNBOLrzsICZVNYa1Rw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FYZ4iVunXxtT+CZqQoPVwPhH7549e/Gy7PIRRtq4t5f/vt54pX6eG9ebttRH6QSH7r/zxAFA4EZGlQ0h0FvXiA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-M/mwDCJ4wLsIgyxv2Lj7Len+UMHd4zAXu4GQ2UaCdksStglWhP61U3uowkaYBQBhVoNpwx5Hputo8eSqM7K82Q=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5jZT2c7jBCrMegKYTYTpni8mg8y3uY8gzeq2ndFOANwNuC/xJbVAoGKR9LhMDA0H3nIhvaqUoBEuJoICBudFrA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.3", "", { "os": "linux", "cpu": "arm" }, "sha512-YeGUhkN1oA+iSPzzhEjVPS29YbViOr8s4lSsFaZKLHswgqP911xx25fPOyE9+khmN6W4VeM0aevbDp4kkEoHiA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.3", "", { "os": "linux", "cpu": "arm" }, "sha512-eo0iOIOvcAlWB3Z3eh8pVM8hZ0oVkK3AjEM9nSrkSug2l15qHzF3TOwT0747omI6+CJJvl7drwZepT+re6Fy/w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-DJay3ep76bKUDImmn//W5SvpjRN5LmK/ntWyeJs/dcnwiiHESd3N4uteK9FDLf0S0W8E6Y0sVRXpOCoQclQqNg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BKKWQkY2WgJ5MC/ayvIJTHjy0JUGb5efaHCUiG/39sSUvAYRBaO3+/EK0AZT1RF3pSj86O24GLLik9mAYu0IJg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.3", "", { "os": "linux", "cpu": "none" }, "sha512-Q9nVlWtKAG7ISW80OiZGxTr6rYtyDSkauHUtvkQI6TNOJjFvpj4gcH+KaJihqYInnAzEEUetPQubRwHef4exVg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.3", "", { "os": "linux", "cpu": "none" }, "sha512-2H5LmhzrpC4fFRNwknzmmTvvyJPHwESoJgyReXeFoYYuIDfBhP29TEXOkCJE/KxHi27mj7wDUClNq78ue3QEBQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9S542V0ie9LCTznPYlvaeySwBeIEa7rDBgLHKZ5S9DBgcqdJYburabm8TqiqG6mrdTzfV5uttQRHcbKff9lWtA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ukxw+YH3XXpcezLgbJeasgxyTbdpnNAkrIlFGDl7t+pgCxZ89/6n1a+MxlY7CegU+nDgrgdqDelPRNQ/47zs0g=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.3", "", { "os": "linux", "cpu": "none" }, "sha512-Iauw9UsTTvlF++FhghFJjqYxyXdggXsOqGpFBylaRopVpcbfyIIsNvkf9oGwfgIcf57z3m8+/oSYTo6HutBFNw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.3", "", { "os": "linux", "cpu": "none" }, "sha512-3OqKAHSEQXKdq9mQ4eajqUgNIK27VZPW3I26EP8miIzuKzCJ3aW3oEn2pzF+4/Hj/Moc0YDsOtBgT5bZ56/vcA=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-0CM8dSVzVIaqMcXIFej8zZrSFLnGrAE8qlNbbHfTw1EEPnFTg1U1ekI0JdzjPyzSfUsHWtodilQQG/RA55berA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.3", "", { "os": "linux", "cpu": "x64" }, "sha512-+fgJE12FZMIgBaKIAGd45rxf+5ftcycANJRWk8Vz0NnMTM5rADPGuRFTYar+Mqs560xuART7XsX2lSACa1iOmQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tMD7NnbAolWPzQlJQJjVFh/fNH3K/KnA7K8gv2dJWCwwnaK6DFCYST1QXYWfu5V0cDwarWC8Sf/cfMHniNq21A=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u5KsqxOxjEeIbn7bUK1MPM34jrnPwjeqgyin4/N6e/KzXKfpE9Mi0nCxcQjaM9lLmPcHmn/xx1yOjgTMtu1jWQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.3", "", { "os": "none", "cpu": "arm64" }, "sha512-vo54aXwjpTtsAnb3ca7Yxs9t2INZg7QdXN/7yaoG7nPGbOBXYXQY41Km+S1Ov26vzOAzLcAjmMdjyEqS1JkVhw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-HI+PIVZ+m+9AgpnY3pt6rinUdRYrGHvmVdsNQ4odNqQ/eRF78DVpMR7mOq7nW06QxpczibwBmeQzB68wJ+4W4A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-vRByotbdMo3Wdi+8oC2nVxtc3RkkFKrGaok+a62AT8lz/YBuQjaVYAS5Zcs3tPzW43Vsf9J0wehJbUY5xRSekA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.3", "", { "os": "win32", "cpu": "x64" }, "sha512-POZHq7UeuzMJljC5NjKi8vKMFN6/5EOqcX1yGntNLp7rUTpBAXQ1hW8kWPFxYLv07QMcNM75xqVLGPWQq6TKFA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.3", "", { "os": "win32", "cpu": "x64" }, "sha512-aPFONczE4fUFKNXszdvnd2GqKEYQdV5oEsIbKPujJmWlCI9zEsv1Otig8RKK+X9bed9gFUN6LAeN4ZcNuu4zjg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun": ["bun@1.3.6", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.6", "@oven/bun-darwin-x64": "1.3.6", "@oven/bun-darwin-x64-baseline": "1.3.6", "@oven/bun-linux-aarch64": "1.3.6", "@oven/bun-linux-aarch64-musl": "1.3.6", "@oven/bun-linux-x64": "1.3.6", "@oven/bun-linux-x64-baseline": "1.3.6", "@oven/bun-linux-x64-musl": "1.3.6", "@oven/bun-linux-x64-musl-baseline": "1.3.6", "@oven/bun-windows-x64": "1.3.6", "@oven/bun-windows-x64-baseline": "1.3.6" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-Tn98GlZVN2WM7+lg/uGn5DzUao37Yc0PUz7yzYHdeF5hd+SmHQGbCUIKE4Sspdgtxn49LunK3mDNBC2Qn6GJjw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||
|
||||
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
|
||||
|
||||
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||
|
||||
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
|
||||
|
||||
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pocketbase": ["pocketbase@0.26.6", "", {}, "sha512-Pl7V4y3DWglYITC4cBpclmuIzePRGsb/sXk/Wyqxznwu5JsHA5IILJY81PT2XQ3OSKCakWjbxjYBqtdcghzKvA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"rollup": ["rollup@4.55.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.3", "@rollup/rollup-android-arm64": "4.55.3", "@rollup/rollup-darwin-arm64": "4.55.3", "@rollup/rollup-darwin-x64": "4.55.3", "@rollup/rollup-freebsd-arm64": "4.55.3", "@rollup/rollup-freebsd-x64": "4.55.3", "@rollup/rollup-linux-arm-gnueabihf": "4.55.3", "@rollup/rollup-linux-arm-musleabihf": "4.55.3", "@rollup/rollup-linux-arm64-gnu": "4.55.3", "@rollup/rollup-linux-arm64-musl": "4.55.3", "@rollup/rollup-linux-loong64-gnu": "4.55.3", "@rollup/rollup-linux-loong64-musl": "4.55.3", "@rollup/rollup-linux-ppc64-gnu": "4.55.3", "@rollup/rollup-linux-ppc64-musl": "4.55.3", "@rollup/rollup-linux-riscv64-gnu": "4.55.3", "@rollup/rollup-linux-riscv64-musl": "4.55.3", "@rollup/rollup-linux-s390x-gnu": "4.55.3", "@rollup/rollup-linux-x64-gnu": "4.55.3", "@rollup/rollup-linux-x64-musl": "4.55.3", "@rollup/rollup-openbsd-x64": "4.55.3", "@rollup/rollup-openharmony-arm64": "4.55.3", "@rollup/rollup-win32-arm64-msvc": "4.55.3", "@rollup/rollup-win32-ia32-msvc": "4.55.3", "@rollup/rollup-win32-x64-gnu": "4.55.3", "@rollup/rollup-win32-x64-msvc": "4.55.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-y9yUpfQvetAjiDLtNMf1hL9NXchIJgWt6zIKeoB+tCd3npX08Eqfzg60V9DhIGVMtQ0AlMkFw5xa+AQ37zxnAA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Frontend entry point - exports auth module for browser
|
||||
export { PocketBaseAuth, initPocketBaseAuth } from '../auth-module/frontend';
|
||||
export type { AuthConfig, AuthState, AuthCallbacks } from '../auth-module/types';
|
||||
@@ -0,0 +1,545 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mode1 - Auth Module Test</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 2rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 2rem;
|
||||
color: #f1f5f9;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-icon.success {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status-icon.pending {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.status-icon.error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #64748b;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.output {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.output.success {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.output.error {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.output.warning {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.info {
|
||||
background: #1e293b;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
margin-top: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔐 Auth Module Test Suite (Mode1)</h1>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Frontend Auth Section -->
|
||||
<div class="section">
|
||||
<h2>
|
||||
<span class="status-icon pending" id="frontendStatus"></span>
|
||||
Frontend: PocketBase Auth
|
||||
</h2>
|
||||
|
||||
<div class="info">
|
||||
Tests OAuth2 login, token refresh, and user state management.
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="testFrontendInit()">Initialize Auth</button>
|
||||
<button onclick="testGetAuthState()">Get Auth State</button>
|
||||
</div>
|
||||
<button onclick="testFrontendLogout()">Logout</button>
|
||||
|
||||
<div class="output" id="frontendOutput"></div>
|
||||
<div class="timestamp" id="frontendTime"></div>
|
||||
</div>
|
||||
|
||||
<!-- Backend Auth Section -->
|
||||
<div class="section">
|
||||
<h2>
|
||||
<span class="status-icon pending" id="backendStatus"></span>
|
||||
Backend: Graph Token & PB Validation
|
||||
</h2>
|
||||
|
||||
<div class="info">
|
||||
Tests Microsoft Graph token acquisition, caching, and PocketBase token validation.
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="testGraphToken()">Get Graph Token</button>
|
||||
<button onclick="testGraphRefresh()">Refresh Graph Token</button>
|
||||
</div>
|
||||
<button onclick="testCompareTokens()">Compare PB vs Graph</button>
|
||||
<button onclick="testPBValidation()">Validate PB Token</button>
|
||||
<button onclick="testCompareTokens()">Compare PB vs Graph</button>
|
||||
<div class="output" id="backendOutput"></div>
|
||||
<div class="timestamp" id="backendTime"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Info Section -->
|
||||
<div class="section">
|
||||
<h2>
|
||||
<span class="status-icon pending" id="jobInfoStatus"></span>
|
||||
Job Info (First 4 Records)
|
||||
</h2>
|
||||
|
||||
<div class="info">
|
||||
Fetch and display first 4 records from Job_Info_Prod collection.
|
||||
</div>
|
||||
|
||||
<button onclick="testJobInfo()">Fetch Job Info</button>
|
||||
|
||||
<div class="output" id="jobInfoOutput"></div>
|
||||
<div class="timestamp" id="jobInfoTime"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login container (shown when not authenticated) -->
|
||||
<div id="loginContainer" class="hidden">
|
||||
<button id="loginBtn">Login with Microsoft</button>
|
||||
<div id="loginError" class="hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- User display (shown when authenticated) -->
|
||||
<div id="userDisplayName"></div>
|
||||
<div id="userEmailValue"></div>
|
||||
|
||||
<script src="/auth-module.js"></script>
|
||||
<script>
|
||||
let pbAuth = null;
|
||||
let config = null;
|
||||
|
||||
// Utility: Log to output
|
||||
function log(elementId, message, type = 'info') {
|
||||
const output = document.getElementById(elementId);
|
||||
if (!output) return;
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const line = `[${timestamp}] ${message}`;
|
||||
|
||||
output.textContent += (output.textContent ? '\n' : '') + line;
|
||||
output.scrollTop = output.scrollHeight;
|
||||
output.className = `output ${type}`;
|
||||
|
||||
// Update timestamp
|
||||
const timeEl = document.getElementById(elementId.replace('Output', 'Time'));
|
||||
if (timeEl) timeEl.textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
function setStatus(elementId, status) {
|
||||
const icon = document.getElementById(elementId);
|
||||
if (!icon) return;
|
||||
icon.className = `status-icon ${status}`;
|
||||
}
|
||||
|
||||
// Test: Load config
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('./api/auth/config');
|
||||
if (!response.ok) throw new Error('Failed to load config');
|
||||
config = await response.json();
|
||||
document.getElementById('configOutput').textContent = JSON.stringify(config, null, 2);
|
||||
document.getElementById('configOutput').className = 'output success';
|
||||
} catch (error) {
|
||||
log('configOutput', `Error loading config: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initialize Frontend Auth
|
||||
async function testFrontendInit() {
|
||||
setStatus('frontendStatus', 'pending');
|
||||
log('frontendOutput', 'Initializing PocketBaseAuth...');
|
||||
|
||||
try {
|
||||
if (typeof window.AuthModule === 'undefined' || !window.AuthModule.initPocketBaseAuth) {
|
||||
throw new Error('Auth module not loaded');
|
||||
}
|
||||
|
||||
pbAuth = await window.AuthModule.initPocketBaseAuth('.', {
|
||||
onAuthSuccess: (state) => {
|
||||
log('frontendOutput', `✓ Auth success: ${state.user?.email}`, 'success');
|
||||
setStatus('frontendStatus', 'success');
|
||||
},
|
||||
onAuthFailure: (error) => {
|
||||
log('frontendOutput', `✗ Auth failed: ${error.message}`, 'error');
|
||||
setStatus('frontendStatus', 'error');
|
||||
},
|
||||
onTokenUpdate: (state) => {
|
||||
log('frontendOutput', `✓ Token refreshed for ${state.user?.email}`, 'success');
|
||||
},
|
||||
onUiUpdate: (state) => {
|
||||
log('frontendOutput', `✓ UI updated: ${state.isAuthenticated ? 'Authenticated' : 'Not authenticated'}`);
|
||||
},
|
||||
});
|
||||
|
||||
log('frontendOutput', '✓ Frontend auth initialized', 'success');
|
||||
setStatus('frontendStatus', 'success');
|
||||
} catch (error) {
|
||||
log('frontendOutput', `✗ Initialization failed: ${error.message}`, 'error');
|
||||
setStatus('frontendStatus', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Get Auth State
|
||||
function testGetAuthState() {
|
||||
if (!pbAuth) {
|
||||
log('frontendOutput', '✗ Auth not initialized', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const state = pbAuth.getAuthState();
|
||||
log('frontendOutput', `Auth State: ${JSON.stringify(state, null, 2)}`, 'success');
|
||||
}
|
||||
|
||||
// Test: Frontend Logout
|
||||
function testFrontendLogout() {
|
||||
if (!pbAuth) {
|
||||
log('frontendOutput', '✗ Auth not initialized', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
pbAuth.logout();
|
||||
log('frontendOutput', '✓ Logged out', 'success');
|
||||
setStatus('frontendStatus', 'pending');
|
||||
}
|
||||
|
||||
// Test: Get Graph Token
|
||||
async function testGraphToken() {
|
||||
setStatus('backendStatus', 'pending');
|
||||
log('backendOutput', 'Requesting Graph token from backend...');
|
||||
|
||||
try {
|
||||
const response = await fetch('./api/auth/graph/token');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
let output = `✓ Token acquired\n`;
|
||||
output += `Expires: ${data.expiresOn}\n`;
|
||||
output += `Valid in cache: ${data.isValid}\n\n`;
|
||||
|
||||
if (data.tokenDetails && typeof data.tokenDetails === 'object') {
|
||||
output += `Token Details:\n`;
|
||||
output += ` Audience: ${data.tokenDetails.aud}\n`;
|
||||
output += ` Issuer: ${data.tokenDetails.iss}\n`;
|
||||
output += ` Scopes: ${data.tokenDetails.scp}\n`;
|
||||
output += ` App: ${data.tokenDetails.app_displayname}\n`;
|
||||
output += ` Issued: ${data.tokenDetails.iat}\n`;
|
||||
output += ` Expires: ${data.tokenDetails.exp}\n\n`;
|
||||
}
|
||||
|
||||
output += `Full Token (first 100 chars):\n${data.fullToken.substring(0, 100)}...`;
|
||||
|
||||
log('backendOutput', output, 'success');
|
||||
setStatus('backendStatus', 'success');
|
||||
} else {
|
||||
log('backendOutput', `✗ ${data.error}`, 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log('backendOutput', `✗ Request failed: ${error.message}`, 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Refresh Graph Token
|
||||
async function testGraphRefresh() {
|
||||
setStatus('backendStatus', 'pending');
|
||||
log('backendOutput', 'Refreshing Graph token...');
|
||||
|
||||
try {
|
||||
const response = await fetch('./api/auth/refresh-graph', { method: 'POST' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
log('backendOutput', `✓ Token refreshed\nExpires: ${data.expiresOn}`, 'success');
|
||||
setStatus('backendStatus', 'success');
|
||||
} else {
|
||||
log('backendOutput', `✗ ${data.error}`, 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log('backendOutput', `✗ Refresh failed: ${error.message}`, 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Compare tokens
|
||||
async function testCompareTokens() {
|
||||
setStatus('backendStatus', 'pending');
|
||||
log('backendOutput', 'Comparing PocketBase token vs Graph token...');
|
||||
|
||||
if (!pbAuth) {
|
||||
log('backendOutput', '✗ Auth not initialized', 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = pbAuth.getAuthState();
|
||||
const pbToken = state.token || null;
|
||||
|
||||
const response = await fetch('./api/auth/compare-tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pbToken }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.comparison) {
|
||||
let output = `✓ Token Comparison:\n\n`;
|
||||
|
||||
output += `GRAPH TOKEN (Backend/App-only):\n`;
|
||||
output += ` Type: ${data.comparison.graph_token.type}\n`;
|
||||
output += ` Source: ${data.comparison.graph_token.source}\n`;
|
||||
output += ` Issuer: ${data.comparison.graph_token.issuer}\n`;
|
||||
output += ` Audience: ${data.comparison.graph_token.audience}\n`;
|
||||
output += ` App: ${data.comparison.graph_token.app}\n`;
|
||||
output += ` Scopes: ${data.comparison.graph_token.scopes}\n`;
|
||||
output += ` Purpose: ${data.comparison.graph_token.purpose}\n\n`;
|
||||
|
||||
output += `POCKETBASE TOKEN (Frontend/User):\n`;
|
||||
output += ` Type: ${data.comparison.pocketbase_token.type}\n`;
|
||||
output += ` Source: ${data.comparison.pocketbase_token.source}\n`;
|
||||
output += ` User: ${data.comparison.pocketbase_token.authenticated_user}\n`;
|
||||
output += ` Purpose: ${data.comparison.pocketbase_token.purpose}\n\n`;
|
||||
|
||||
output += `COMPARISON:\n`;
|
||||
output += ` Are Different: YES ✓\n`;
|
||||
output += ` ${data.comparison.explanation}\n`;
|
||||
|
||||
log('backendOutput', output, 'success');
|
||||
setStatus('backendStatus', 'success');
|
||||
} else {
|
||||
log('backendOutput', `✗ ${data.error}`, 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log('backendOutput', `✗ Comparison failed: ${error.message}`, 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Validate PocketBase Token
|
||||
async function testPBValidation() {
|
||||
setStatus('backendStatus', 'pending');
|
||||
log('backendOutput', 'Validating PocketBase token...');
|
||||
|
||||
if (!pbAuth) {
|
||||
log('backendOutput', '✗ Auth not initialized', 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = pbAuth.getAuthState();
|
||||
|
||||
if (!state.token) {
|
||||
log('backendOutput', '✗ No token available - user not logged in', 'warning');
|
||||
setStatus('backendStatus', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
log('backendOutput', `Sending token for user: ${state.user?.email}...`);
|
||||
|
||||
const response = await fetch('./api/auth/validate-pb-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pbToken: state.token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid) {
|
||||
log('backendOutput', `✓ Token valid\nUser: ${data.user?.email}\nID: ${data.user?.id}`, 'success');
|
||||
setStatus('backendStatus', 'success');
|
||||
} else {
|
||||
log('backendOutput', `✗ Token invalid: ${data.error}`, 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log('backendOutput', `✗ Validation failed: ${error.message}`, 'error');
|
||||
setStatus('backendStatus', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Fetch Job Info
|
||||
async function testJobInfo() {
|
||||
setStatus('jobInfoStatus', 'pending');
|
||||
log('jobInfoOutput', 'Fetching first 4 records from Job_Info_Prod...');
|
||||
|
||||
if (!pbAuth) {
|
||||
log('jobInfoOutput', '✗ Auth not initialized - cannot fetch records', 'error');
|
||||
setStatus('jobInfoStatus', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pb = pbAuth.getPocketBase();
|
||||
const records = await pb.collection('Job_Info_Prod').getList(1, 4);
|
||||
|
||||
if (!records.items || records.items.length === 0) {
|
||||
log('jobInfoOutput', 'No records found', 'warning');
|
||||
setStatus('jobInfoStatus', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
let output = `✓ Found ${records.items.length} records:\n\n`;
|
||||
|
||||
records.items.forEach((record, index) => {
|
||||
output += `[${index + 1}] ID: ${record.id}\n`;
|
||||
output += ` Title: ${record.title || record.name || 'N/A'}\n`;
|
||||
output += ` Status: ${record.status || 'N/A'}\n`;
|
||||
output += ` Created: ${new Date(record.created).toLocaleDateString()}\n\n`;
|
||||
});
|
||||
|
||||
log('jobInfoOutput', output, 'success');
|
||||
setStatus('jobInfoStatus', 'success');
|
||||
} catch (error) {
|
||||
log('jobInfoOutput', `✗ Failed to fetch records: ${error.message}`, 'error');
|
||||
setStatus('jobInfoStatus', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "mode1-auth-test",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "bun run backend/server.ts",
|
||||
"dev": "bun --watch backend/server.ts",
|
||||
"build": "vite build",
|
||||
"prebuild": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.0.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"pocketbase": "^0.26.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'frontend/dist',
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: 'frontend/auth.ts',
|
||||
name: 'AuthModule',
|
||||
fileName: () => 'auth.js',
|
||||
formats: ['umd']
|
||||
}
|
||||
},
|
||||
server: {
|
||||
middlewareMode: true
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,313 @@
|
||||
# Mode1Svelte - SvelteKit Auth Testing Suite
|
||||
|
||||
A complete SvelteKit-based testing environment for authentication workflows with Microsoft Azure MSAL and PocketBase. This is a modern rewrite of Mode1 using the latest SvelteKit technology stack.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: SvelteKit 2.x with TypeScript
|
||||
- **Backend**: Node.js Adapter (production-ready)
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **Authentication**:
|
||||
- Azure MSAL Node (@azure/msal-node) - Microsoft Graph token management
|
||||
- PocketBase - User OAuth2 and token validation
|
||||
- **Build Tool**: Vite (integrated with SvelteKit)
|
||||
- **Runtime**: Node.js 18+
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Frontend Authentication (PocketBase)
|
||||
- OAuth2 login initialization with Microsoft
|
||||
- Auth state management and retrieval
|
||||
- Token refresh and validation
|
||||
- Session persistence
|
||||
- Automatic logout on invalid tokens
|
||||
|
||||
### ✅ Backend Token Management (Microsoft Graph)
|
||||
- App-only token acquisition via MSAL
|
||||
- Automatic token caching with 60-second expiration buffer
|
||||
- Token refresh on demand
|
||||
- JWT payload inspection
|
||||
|
||||
### ✅ Token Validation & Comparison
|
||||
- PocketBase user token validation
|
||||
- User record retrieval from tokens
|
||||
- Side-by-side token comparison (Graph vs PocketBase)
|
||||
- Token payload analysis
|
||||
|
||||
### ✅ Interactive Testing Dashboard
|
||||
- Real-time authentication status
|
||||
- Visual token testing interface
|
||||
- API endpoint testing
|
||||
- Response inspection
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Mode1Svelte/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ └── auth/
|
||||
│ │ ├── types.ts # TypeScript interfaces
|
||||
│ │ ├── backend.ts # Backend auth classes (MSAL, PocketBase)
|
||||
│ │ └── frontend.ts # Frontend PocketBase auth class
|
||||
│ ├── routes/
|
||||
│ │ ├── +layout.svelte # App layout
|
||||
│ │ ├── +page.svelte # Main dashboard
|
||||
│ │ └── api/
|
||||
│ │ └── auth/
|
||||
│ │ ├── config/ # GET /api/auth/config
|
||||
│ │ ├── graph/ # GET /api/auth/graph/token
|
||||
│ │ ├── validate-pb-token/ # POST /api/auth/validate-pb-token
|
||||
│ │ ├── refresh-graph/ # POST /api/auth/refresh-graph
|
||||
│ │ └── compare-tokens/ # POST /api/auth/compare-tokens
|
||||
│ └── app.css # Tailwind directives
|
||||
├── static/ # Static assets
|
||||
├── package.json # Dependencies
|
||||
├── svelte.config.js # SvelteKit config (Node adapter)
|
||||
├── tailwind.config.js # Tailwind CSS config
|
||||
├── vite.config.ts # Vite config
|
||||
└── tsconfig.json # TypeScript config
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd Mode1Svelte
|
||||
npm install
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Ensure your `.env` file or environment variables are set:
|
||||
|
||||
```bash
|
||||
export CLIENT_ID="<Azure Client ID>"
|
||||
export TENANT_ID="<Azure Tenant ID>"
|
||||
export CLIENT_SECRET="<Azure Client Secret>"
|
||||
export PB_URL="<PocketBase URL>"
|
||||
export PB_DB="<PocketBase Database URL>"
|
||||
export PORT=5173
|
||||
```
|
||||
|
||||
For development, create `/home/admin/secrets/.env`:
|
||||
|
||||
```
|
||||
CLIENT_ID=your_client_id
|
||||
TENANT_ID=your_tenant_id
|
||||
CLIENT_SECRET=your_client_secret
|
||||
PB_URL=https://pocketbase.example.com
|
||||
PB_DB=https://pocketbase.example.com
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Development Mode
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Starts the dev server at `http://localhost:5173` with hot reloading.
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
Builds the application and runs the Node.js server.
|
||||
|
||||
### Type Checking
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
Run TypeScript type checking across the project.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET `/api/auth/config`
|
||||
Returns frontend-safe configuration for PocketBase OAuth setup.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"pbUrl": "https://pocketbase.example.com",
|
||||
"provider": "microsoft",
|
||||
"collection": "Users"
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/auth/graph/token`
|
||||
Fetches a Microsoft Graph token for backend API access.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJ0eXAiOiJKV1Q...",
|
||||
"fullToken": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||
"expiresOn": "2026-01-23T02:15:00.000Z",
|
||||
"isValid": true,
|
||||
"tokenDetails": {
|
||||
"aud": "https://graph.microsoft.com",
|
||||
"iss": "https://sts.windows.net/tenant-id",
|
||||
"scp": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/auth/validate-pb-token`
|
||||
Validates a PocketBase user token and retrieves user information.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"pbToken": "user_token_string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"email": "user@example.com",
|
||||
"name": "User Name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/auth/refresh-graph`
|
||||
Clears the cached Graph token and acquires a new one.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"refreshed": true,
|
||||
"expiresOn": "2026-01-23T02:15:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/auth/compare-tokens`
|
||||
Compares Graph token (backend) and PocketBase token (user) to demonstrate their differences.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"pbToken": "user_token_string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"comparison": {
|
||||
"graph_token": {
|
||||
"issuer": "https://sts.windows.net/tenant",
|
||||
"audience": "https://graph.microsoft.com",
|
||||
"app": "App Name",
|
||||
"scopes": "...",
|
||||
"issued_at": "2026-01-23T01:15:00.000Z",
|
||||
"expires_at": "2026-01-23T02:15:00.000Z",
|
||||
"type": "Microsoft Graph Token (Backend/App-only)",
|
||||
"source": "@azure/msal-node",
|
||||
"purpose": "Backend API calls to Microsoft Graph"
|
||||
},
|
||||
"pocketbase_token": {
|
||||
"type": "PocketBase User Token (Frontend/User)",
|
||||
"source": "PocketBase OAuth2 flow",
|
||||
"authenticated_user": "user@example.com"
|
||||
},
|
||||
"are_different": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Frontend (Browser)
|
||||
1. User clicks "Login with Microsoft"
|
||||
2. PocketBase initiates OAuth2 flow with Microsoft provider
|
||||
3. Microsoft redirects back with authorization code
|
||||
4. PocketBase exchanges code for user token
|
||||
5. Token stored in PocketBase auth store
|
||||
6. User dashboard updates with authenticated state
|
||||
|
||||
### Backend (Server)
|
||||
1. Application starts with MSAL configuration
|
||||
2. On first API request, acquires Graph token using client credentials
|
||||
3. Token cached for 55 minutes (60-minute lifetime - 5-minute buffer)
|
||||
4. Subsequent requests use cached token
|
||||
5. Token automatically refreshed when expired
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **CORS**: Configured for `localhost:5173` and production URLs
|
||||
- **Token Caching**: Graph tokens cached in memory; refresh on demand with `/api/auth/refresh-graph`
|
||||
- **Error Handling**: Comprehensive error messages with specific failure reasons
|
||||
- **Type Safety**: Full TypeScript support throughout frontend and backend
|
||||
- **SvelteKit Routing**: File-based routing with `+page.svelte` and `+server.ts` files
|
||||
|
||||
## Comparison with Mode1 (Hono/Bun)
|
||||
|
||||
| Feature | Mode1Svelte | Mode1 |
|
||||
|---------|-------------|--------|
|
||||
| Framework | SvelteKit 2 | SvelteKit 4 (old) |
|
||||
| Backend | Node.js Adapter | Hono + Bun |
|
||||
| Frontend | Svelte Components | HTML + Vanilla JS |
|
||||
| Styling | Tailwind CSS | CSS (custom) |
|
||||
| Build Tool | Vite | Vite |
|
||||
| API Routes | `/routes/api/` | `/routes/api/` |
|
||||
| Auth Module | `/lib/auth/` | `/routes/api/` |
|
||||
| Type Safety | Full TypeScript | TypeScript (partial) |
|
||||
| Production Ready | ✅ Yes | ⚠️ Experimental |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token Acquisition Fails
|
||||
- Verify `CLIENT_ID`, `TENANT_ID`, and `CLIENT_SECRET` are correct
|
||||
- Ensure the Azure application is configured for client credentials flow
|
||||
- Check that the application has Graph API permissions
|
||||
|
||||
### PocketBase Token Validation Fails
|
||||
- Verify `PB_URL` and `PB_DB` are correct
|
||||
- Ensure the user is properly logged in via OAuth
|
||||
- Check that PocketBase is running and accessible
|
||||
|
||||
### Login Button Not Appearing
|
||||
- Ensure JavaScript is enabled
|
||||
- Check browser console for errors
|
||||
- Verify that `loginBtn` HTML element ID matches configuration
|
||||
|
||||
## Links
|
||||
|
||||
- [SvelteKit Documentation](https://svelte.dev/docs/kit)
|
||||
- [Azure MSAL Node](https://github.com/AzureAD/microsoft-authentication-library-for-js)
|
||||
- [PocketBase Documentation](https://pocketbase.io/docs/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
|
||||
## License
|
||||
|
||||
Private - Job Info Testing Suite
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "mode1svelte",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3005",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "PORT=3005 node build/index.js",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.0.2",
|
||||
"@types/node": "^25.0.10",
|
||||
"dotenv": "^17.2.3",
|
||||
"pocketbase": "^0.26.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#1e293b" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,204 @@
|
||||
import { config } from 'dotenv';
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node';
|
||||
import PocketBase from 'pocketbase';
|
||||
import type { GraphTokenCache, BackendAuthConfig } from './types';
|
||||
|
||||
// Load environment variables from shared secrets directory
|
||||
config({ path: '/home/admin/secrets/.env' });
|
||||
|
||||
/**
|
||||
* Configuration Manager
|
||||
* Exposes frontend-safe configuration loaded from environment
|
||||
*/
|
||||
export class AuthConfigManager {
|
||||
/**
|
||||
* Get frontend configuration (safe to expose to browser)
|
||||
*/
|
||||
static getFrontendConfig() {
|
||||
return {
|
||||
pbUrl: process.env.PB_URL!,
|
||||
provider: 'microsoft',
|
||||
collection: 'Users',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all backend secrets (never expose to frontend)
|
||||
*/
|
||||
static getBackendConfig() {
|
||||
return {
|
||||
clientId: process.env.CLIENT_ID!,
|
||||
tenantId: process.env.TENANT_ID!,
|
||||
clientSecret: process.env.CLIENT_SECRET!,
|
||||
pbDb: process.env.PB_DB!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft Graph Token Management (Backend)
|
||||
* Handles token acquisition and caching for backend Graph API calls
|
||||
*/
|
||||
export class GraphTokenManager {
|
||||
private cca: ConfidentialClientApplication;
|
||||
private cache: GraphTokenCache | null = null;
|
||||
private config: Required<BackendAuthConfig>;
|
||||
|
||||
constructor(config: BackendAuthConfig) {
|
||||
this.config = {
|
||||
clientId: config.clientId || process.env.CLIENT_ID || '',
|
||||
tenantId: config.tenantId || process.env.TENANT_ID || '',
|
||||
clientSecret: config.clientSecret || process.env.CLIENT_SECRET || '',
|
||||
};
|
||||
|
||||
this.cca = new ConfidentialClientApplication({
|
||||
auth: {
|
||||
clientId: this.config.clientId,
|
||||
authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
|
||||
clientSecret: this.config.clientSecret,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Graph token (from cache or acquire new)
|
||||
*/
|
||||
async getToken(): Promise<string> {
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache validity (with 60s buffer for expiration)
|
||||
if (this.cache && this.cache.expiresOn - 60000 > now) {
|
||||
return this.cache.token;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.cca.acquireTokenByClientCredential({
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
});
|
||||
|
||||
if (!result?.accessToken) {
|
||||
throw new Error('Failed to acquire Graph token');
|
||||
}
|
||||
|
||||
const expiresOn = result.expiresOn
|
||||
? new Date(result.expiresOn).getTime()
|
||||
: now + 55 * 60 * 1000; // Default 55 minutes
|
||||
|
||||
this.cache = {
|
||||
token: result.accessToken,
|
||||
expiresOn,
|
||||
};
|
||||
|
||||
return result.accessToken;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to acquire Graph token: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token with expiration info
|
||||
*/
|
||||
async getTokenWithExpiry(): Promise<{ token: string; expiresOnISO: string }> {
|
||||
const token = await this.getToken();
|
||||
const expiresOn = this.cache?.expiresOn || Date.now();
|
||||
return {
|
||||
token,
|
||||
expiresOnISO: new Date(expiresOn).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is cached and valid
|
||||
*/
|
||||
isTokenValid(): boolean {
|
||||
if (!this.cache) return false;
|
||||
const now = Date.now();
|
||||
return this.cache.expiresOn - 60000 > now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache (force refresh on next call)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PocketBase Token Validation (Backend)
|
||||
* Validates and uses user PocketBase tokens
|
||||
*/
|
||||
export class PocketBaseValidator {
|
||||
private pb: PocketBase;
|
||||
|
||||
constructor(pbUrl?: string) {
|
||||
this.pb = new PocketBase(pbUrl || process.env.PB_DB!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user token and validate it
|
||||
*/
|
||||
async validateUserToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
this.pb.authStore.save(token, null);
|
||||
await this.pb.collection('Users').authRefresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Token validation failed:', error instanceof Error ? error.message : error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user record from token
|
||||
*/
|
||||
async getUserRecord(token: string): Promise<any> {
|
||||
try {
|
||||
this.pb.authStore.save(token, null);
|
||||
const record = this.pb.authStore.record || this.pb.authStore.model;
|
||||
return record;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user record:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PocketBase instance
|
||||
*/
|
||||
getPocketBase(): PocketBase {
|
||||
return this.pb;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined backend auth manager
|
||||
*/
|
||||
export class BackendAuth {
|
||||
graphTokenManager: GraphTokenManager;
|
||||
pbValidator: PocketBaseValidator;
|
||||
|
||||
constructor(config: BackendAuthConfig, pbUrl?: string) {
|
||||
this.graphTokenManager = new GraphTokenManager(config);
|
||||
this.pbValidator = new PocketBaseValidator(pbUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate PocketBase token in requests
|
||||
* Usage: app.use('/*', (c, next) => backendAuth.validateTokenMiddleware(c, next))
|
||||
*/
|
||||
async validateTokenMiddleware(c: any, next: any): Promise<any> {
|
||||
const token = c.req.header('Authorization')?.replace('Bearer ', '') ||
|
||||
(await c.req.json().catch(() => ({})))?.pbToken;
|
||||
|
||||
if (token) {
|
||||
const isValid = await this.pbValidator.validateUserToken(token);
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid token' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import type { AuthConfig, AuthState, AuthCallbacks } from './types';
|
||||
|
||||
/**
|
||||
* PocketBase OAuth2 Frontend Module
|
||||
* Handles user authentication and token management
|
||||
*/
|
||||
export class PocketBaseAuth {
|
||||
private pb: PocketBase;
|
||||
private config: Required<AuthConfig>;
|
||||
private callbacks: AuthCallbacks;
|
||||
private state: AuthState;
|
||||
|
||||
constructor(config: AuthConfig, callbacks?: Partial<AuthCallbacks>) {
|
||||
this.config = {
|
||||
pbUrl: config.pbUrl!,
|
||||
collection: config.collection || 'Users',
|
||||
provider: config.provider || 'microsoft',
|
||||
loginContainerId: config.loginContainerId || 'loginContainer',
|
||||
userDisplayNameId: config.userDisplayNameId || 'userDisplayName',
|
||||
userEmailId: config.userEmailId || 'userEmailValue',
|
||||
loginBtnId: config.loginBtnId || 'loginBtn',
|
||||
loginErrorId: config.loginErrorId || 'loginError',
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
onAuthSuccess: callbacks?.onAuthSuccess || (() => {}),
|
||||
onAuthFailure: callbacks?.onAuthFailure || (() => {}),
|
||||
onTokenUpdate: callbacks?.onTokenUpdate || (() => {}),
|
||||
onUiUpdate: callbacks?.onUiUpdate || (() => {}),
|
||||
};
|
||||
|
||||
this.pb = new PocketBase(this.config.pbUrl);
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth module
|
||||
*/
|
||||
private init(): void {
|
||||
this.updateAuthUI();
|
||||
if (this.pb.authStore.isValid && this.pb.authStore.token) {
|
||||
this.ensureUserLogged();
|
||||
}
|
||||
this.setupLoginButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup login button click handler
|
||||
*/
|
||||
private setupLoginButton(): void {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId);
|
||||
if (!loginBtn) {
|
||||
console.warn(`Login button with id "${this.config.loginBtnId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
loginBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.handleLogin();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login button click
|
||||
*/
|
||||
private async handleLogin(): Promise<void> {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
|
||||
const loginError = document.getElementById(this.config.loginErrorId) as HTMLElement;
|
||||
|
||||
if (!loginBtn || !loginError) return;
|
||||
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Checking session...';
|
||||
loginError.classList.add('hidden');
|
||||
|
||||
try {
|
||||
// Try to use existing token first
|
||||
const hadToken = await this.ensureUserLogged();
|
||||
if (hadToken) {
|
||||
this.resetLoginButton();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise perform OAuth
|
||||
loginBtn.textContent = 'Logging in...';
|
||||
const authData = await this.pb.collection(this.config.collection).authWithOAuth2({
|
||||
provider: this.config.provider,
|
||||
});
|
||||
|
||||
this.updateState(authData);
|
||||
this.updateAuthUI();
|
||||
this.callbacks.onAuthSuccess?.(this.state);
|
||||
|
||||
console.log('✓ Logged in with', this.config.provider);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Login failed:', message);
|
||||
loginError.textContent = `Login failed: ${message}`;
|
||||
loginError.classList.remove('hidden');
|
||||
this.callbacks.onAuthFailure?.(error);
|
||||
} finally {
|
||||
this.resetLoginButton();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user is logged in (check existing token)
|
||||
*/
|
||||
async ensureUserLogged(): Promise<boolean> {
|
||||
if (!this.pb.authStore.isValid || !this.pb.authStore.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const refresh = await this.pb.collection(this.config.collection).authRefresh();
|
||||
this.updateState(refresh);
|
||||
this.updateAuthUI();
|
||||
this.callbacks.onTokenUpdate?.(this.state);
|
||||
console.log('✓ Token refreshed');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Existing token invalid, clearing auth');
|
||||
this.pb.authStore.clear();
|
||||
this.updateState(null);
|
||||
this.updateAuthUI();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update internal auth state
|
||||
*/
|
||||
private updateState(data: any): void {
|
||||
if (!data) {
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const record = data.record || this.pb.authStore.record || this.pb.authStore.model;
|
||||
const meta = data.meta || {};
|
||||
const model = this.pb.authStore.model;
|
||||
|
||||
this.state = {
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
id: record?.id || model?.id || 'unknown',
|
||||
name: record?.name || model?.name || meta?.name || record?.email || model?.email || 'Unknown User',
|
||||
email: record?.email || model?.email || meta?.email || '(no email)',
|
||||
},
|
||||
token: this.pb.authStore.token,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI based on auth state
|
||||
*/
|
||||
updateAuthUI(): void {
|
||||
const loginContainer = document.getElementById(this.config.loginContainerId);
|
||||
const userDisplayName = document.getElementById(this.config.userDisplayNameId);
|
||||
const userEmail = document.getElementById(this.config.userEmailId);
|
||||
|
||||
if (!loginContainer) return;
|
||||
|
||||
if (this.pb.authStore.isValid) {
|
||||
loginContainer.classList.add('hidden');
|
||||
if (this.state.user) {
|
||||
if (userDisplayName) userDisplayName.textContent = this.state.user.name;
|
||||
if (userEmail) userEmail.textContent = this.state.user.email;
|
||||
}
|
||||
} else {
|
||||
loginContainer.classList.remove('hidden');
|
||||
const loginError = document.getElementById(this.config.loginErrorId);
|
||||
if (loginError) loginError.classList.add('hidden');
|
||||
}
|
||||
|
||||
this.callbacks.onUiUpdate?.(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check token status (for verification/display)
|
||||
*/
|
||||
async checkTokenStatus(): Promise<{ pbToken: boolean; tokenExpiry?: string }> {
|
||||
const pbToken = !!this.pb.authStore.token;
|
||||
return { pbToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auth state
|
||||
*/
|
||||
getAuthState(): AuthState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PocketBase instance (for direct usage if needed)
|
||||
*/
|
||||
getPocketBase(): PocketBase {
|
||||
return this.pb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
logout(): void {
|
||||
this.pb.authStore.clear();
|
||||
this.updateState(null);
|
||||
this.updateAuthUI();
|
||||
console.log('✓ Logged out');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset login button to initial state
|
||||
*/
|
||||
private resetLoginButton(): void {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
|
||||
if (loginBtn) {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Login with Microsoft';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick init function - fetches config from backend
|
||||
*/
|
||||
export async function initPocketBaseAuth(
|
||||
backendUrl: string,
|
||||
callbacks?: Partial<AuthCallbacks>
|
||||
): Promise<PocketBaseAuth> {
|
||||
// Fetch frontend config from backend
|
||||
const response = await fetch(`${backendUrl}/api/auth/config`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch auth configuration from backend');
|
||||
}
|
||||
const config = await response.json();
|
||||
|
||||
const auth = new PocketBaseAuth(config, callbacks);
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Frontend Auth Configuration
|
||||
*/
|
||||
export interface AuthConfig {
|
||||
pbUrl: string; // PocketBase URL (required)
|
||||
collection?: string;
|
||||
provider?: string;
|
||||
loginContainerId?: string;
|
||||
userDisplayNameId?: string;
|
||||
userEmailId?: string;
|
||||
loginBtnId?: string;
|
||||
loginErrorId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend configuration provided by backend
|
||||
*/
|
||||
export interface FrontendConfig {
|
||||
pbUrl: string;
|
||||
provider?: string;
|
||||
collection?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend Auth Configuration
|
||||
*/
|
||||
export interface BackendAuthConfig {
|
||||
clientId?: string;
|
||||
tenantId?: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state object
|
||||
*/
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
} | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph token cache object
|
||||
*/
|
||||
export interface GraphTokenCache {
|
||||
token: string;
|
||||
expiresOn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth event callbacks
|
||||
*/
|
||||
export interface AuthCallbacks {
|
||||
onAuthSuccess?: (state: AuthState) => void;
|
||||
onAuthFailure?: (error: any) => void;
|
||||
onTokenUpdate?: (state: AuthState) => void;
|
||||
onUiUpdate?: (state: AuthState) => void;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { PocketBaseAuth } from '$lib/auth/frontend';
|
||||
import type { AuthState } from '$lib/auth/types';
|
||||
|
||||
let auth: PocketBaseAuth | undefined;
|
||||
let authState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
};
|
||||
let graphToken = '';
|
||||
let pbTokenValid = false;
|
||||
let comparisonData: string | null = null;
|
||||
let loading = false;
|
||||
let error = '';
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/config');
|
||||
if (!response.ok) throw new Error('Failed to get config');
|
||||
const config = await response.json();
|
||||
|
||||
auth = new PocketBaseAuth(config, {
|
||||
onAuthSuccess: (state: AuthState) => {
|
||||
authState = state;
|
||||
console.log('Auth success:', state);
|
||||
},
|
||||
onAuthFailure: (err: unknown) => {
|
||||
error = 'Authentication failed: ' + (err instanceof Error ? err.message : 'Unknown error');
|
||||
console.error('Auth error:', err);
|
||||
},
|
||||
onTokenUpdate: (state: AuthState) => {
|
||||
authState = state;
|
||||
},
|
||||
onUiUpdate: (state: AuthState) => {
|
||||
authState = state;
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleFetchGraphToken() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch('/api/auth/graph/token');
|
||||
if (!response.ok) throw new Error('Failed to fetch Graph token');
|
||||
const data = await response.json();
|
||||
graphToken = JSON.stringify(data, null, 2);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshGraphToken() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh-graph', { method: 'POST' });
|
||||
if (!response.ok) throw new Error('Failed to refresh');
|
||||
const data = await response.json();
|
||||
graphToken = JSON.stringify(data, null, 2);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleValidatePBToken() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const pbToken = auth?.getPocketBase?.()?.authStore?.token;
|
||||
if (!pbToken) {
|
||||
error = 'No PocketBase token found. Please log in first.';
|
||||
pbTokenValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/validate-pb-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pbToken }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Validation failed');
|
||||
const data = await response.json();
|
||||
pbTokenValid = data.valid;
|
||||
error = data.valid ? '' : 'Token validation failed';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Unknown error';
|
||||
pbTokenValid = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompareTokens() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const pbToken = auth?.getPocketBase?.()?.authStore?.token;
|
||||
const response = await fetch('/api/auth/compare-tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pbToken }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Comparison failed');
|
||||
const data = await response.json();
|
||||
comparisonData = JSON.stringify(data, null, 2);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
if (auth) {
|
||||
auth.logout();
|
||||
authState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
};
|
||||
error = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="min-height: 100vh; background: linear-gradient(to bottom, #0f172a, #1e293b); color: white; padding: 16px; padding-bottom: 100px;">
|
||||
<div style="max-width: 800px; margin: 0 auto;">
|
||||
<!-- Header -->
|
||||
<div style="margin-bottom: 32px;">
|
||||
<h1 style="font-size: 28px; font-weight: bold; margin: 0 0 8px 0;">Mode1Svelte</h1>
|
||||
<p style="font-size: 14px; color: #94a3b8; margin: 0;">Auth Testing Dashboard</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div style="background: rgba(127, 29, 29, 0.2); border: 1px solid #dc2626; color: #fca5a5; padding: 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px;">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Auth Status -->
|
||||
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 16px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<h2 style="font-size: 20px; font-weight: bold; margin: 0 0 12px 0;">Authentication Status</h2>
|
||||
|
||||
{#if authState.isAuthenticated && authState.user !== null}
|
||||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<div style="font-size: 14px;">
|
||||
<span style="color: #cbd5e1;">Status:</span>
|
||||
<span style="margin-left: 8px; color: #4ade80; font-weight: bold;">✓ Authenticated</span>
|
||||
</div>
|
||||
<div style="font-size: 14px; word-break: break-all;">
|
||||
<span style="color: #cbd5e1;">User:</span>
|
||||
<span style="margin-left: 8px; color: #93c5fd;" id="userDisplayName">{authState.user?.name}</span>
|
||||
</div>
|
||||
<div style="font-size: 14px; word-break: break-all;">
|
||||
<span style="color: #cbd5e1;">Email:</span>
|
||||
<span style="margin-left: 8px; color: #93c5fd;" id="userEmailValue">{authState.user?.email}</span>
|
||||
</div>
|
||||
<button
|
||||
on:click={handleLogout}
|
||||
style="background: #dc2626; color: white; border: none; padding: 12px 16px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; margin-top: 8px; width: 100%;"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div id="loginContainer" style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<p style="font-size: 14px; color: #cbd5e1; margin: 0;">Not authenticated. Log in to begin testing.</p>
|
||||
<button
|
||||
id="loginBtn"
|
||||
style="background: #2563eb; color: white; border: none; padding: 16px; border-radius: 6px; font-weight: bold; font-size: 16px; cursor: pointer; width: 100%;"
|
||||
>
|
||||
Login with Microsoft
|
||||
</button>
|
||||
<div id="loginError" style="color: #f87171; font-size: 12px; display: none;"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- API Testing -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px;">
|
||||
<!-- Graph Token -->
|
||||
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px;">
|
||||
<h3 style="font-size: 16px; font-weight: bold; margin: 0 0 12px 0;">Graph Token</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<button
|
||||
on:click={handleFetchGraphToken}
|
||||
disabled={loading}
|
||||
style="background: #4f46e5; color: white; border: none; padding: 10px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; opacity: {loading ? 0.6 : 1}; width: 100%;"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Fetch Token'}
|
||||
</button>
|
||||
<button
|
||||
on:click={handleRefreshGraphToken}
|
||||
disabled={loading}
|
||||
style="background: #4f46e5; color: white; border: none; padding: 10px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; opacity: {loading ? 0.6 : 1}; width: 100%;"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PocketBase Token -->
|
||||
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px;">
|
||||
<h3 style="font-size: 16px; font-weight: bold; margin: 0 0 12px 0;">PocketBase Token</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<button
|
||||
on:click={handleValidatePBToken}
|
||||
disabled={loading}
|
||||
style="background: #059669; color: white; border: none; padding: 10px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; opacity: {loading ? 0.6 : 1}; width: 100%;"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Validate'}
|
||||
</button>
|
||||
{#if pbTokenValid}
|
||||
<p style="color: #4ade80; font-size: 12px; margin: 0;">✓ Token is valid</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compare Tokens -->
|
||||
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<button
|
||||
on:click={handleCompareTokens}
|
||||
disabled={loading}
|
||||
style="background: #9333ea; color: white; border: none; padding: 12px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; opacity: {loading ? 0.6 : 1}; width: 100%;"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Compare Tokens'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
{#if graphToken}
|
||||
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<h3 style="font-size: 16px; font-weight: bold; margin: 0 0 12px 0;">Graph Token Response</h3>
|
||||
<pre style="background: #0f172a; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 11px; color: #cbd5e1; max-height: 200px; white-space: pre-wrap; word-wrap: break-word; margin: 0;">{graphToken}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if comparisonData}
|
||||
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px;">
|
||||
<h3 style="font-size: 16px; font-weight: bold; margin: 0 0 12px 0;">Token Comparison</h3>
|
||||
<pre style="background: #0f172a; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 11px; color: #cbd5e1; max-height: 200px; white-space: pre-wrap; word-wrap: break-word; margin: 0;">{comparisonData}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { BackendAuth } from '$lib/auth/backend';
|
||||
|
||||
const backendAuth = new BackendAuth(
|
||||
{
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
},
|
||||
process.env.PB_DB
|
||||
);
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const pbToken = body.pbToken;
|
||||
|
||||
// Get Graph token
|
||||
const { token: graphToken, expiresOnISO: graphExpires } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
|
||||
if (!graphToken) {
|
||||
return json({ success: false, error: 'Failed to acquire Graph token' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Decode both tokens to compare
|
||||
const decodeJWT = (token: string) => {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
return JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const graphPayload = decodeJWT(graphToken);
|
||||
|
||||
// Validate PB token to get actual user
|
||||
let authenticatedUser = 'Unknown';
|
||||
if (pbToken) {
|
||||
try {
|
||||
const isValid = await backendAuth.pbValidator.validateUserToken(pbToken);
|
||||
if (isValid) {
|
||||
const userRecord = await backendAuth.pbValidator.getUserRecord(pbToken);
|
||||
authenticatedUser = userRecord?.email || userRecord?.id || 'Unknown';
|
||||
}
|
||||
} catch {
|
||||
authenticatedUser = 'Token invalid or expired';
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
comparison: {
|
||||
graph_token: {
|
||||
issuer: graphPayload?.iss || 'N/A',
|
||||
audience: graphPayload?.aud || 'N/A',
|
||||
app: graphPayload?.app_displayname || 'N/A',
|
||||
scopes: graphPayload?.scp || 'N/A',
|
||||
issued_at: graphPayload?.iat ? new Date(graphPayload.iat * 1000).toISOString() : 'N/A',
|
||||
expires_at: graphPayload?.exp ? new Date(graphPayload.exp * 1000).toISOString() : 'N/A',
|
||||
type: 'Microsoft Graph Token (Backend/App-only)',
|
||||
source: '@azure/msal-node',
|
||||
purpose: 'Backend API calls to Microsoft Graph',
|
||||
},
|
||||
pocketbase_token: {
|
||||
note: 'User token from PocketBase OAuth2',
|
||||
type: 'PocketBase User Token (Frontend/User)',
|
||||
source: 'PocketBase OAuth2 flow',
|
||||
purpose: 'User authentication and authorization',
|
||||
authenticated_user: authenticatedUser,
|
||||
},
|
||||
are_different: true,
|
||||
explanation: 'These are completely different tokens from different systems: Graph token is for app-to-app Microsoft API access, PocketBase token is for user authentication in the application.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to compare tokens' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { AuthConfigManager } from '$lib/auth/backend';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = AuthConfigManager.getFrontendConfig();
|
||||
return json(config);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: 'Failed to get config', message: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { BackendAuth, AuthConfigManager } from '$lib/auth/backend';
|
||||
|
||||
const backendAuth = new BackendAuth(
|
||||
{
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
},
|
||||
process.env.PB_DB
|
||||
);
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const { token, expiresOnISO } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
|
||||
if (!token) {
|
||||
return json({ success: false, error: 'Failed to acquire Graph token' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Decode JWT to show token details (for verification)
|
||||
const parts = token.split('.');
|
||||
let payload: any = {};
|
||||
if (parts.length === 3) {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
payload = {
|
||||
aud: decoded.aud,
|
||||
iss: decoded.iss,
|
||||
scp: decoded.scp,
|
||||
app_displayname: decoded.app_displayname,
|
||||
iat: new Date(decoded.iat * 1000).toISOString(),
|
||||
exp: new Date(decoded.exp * 1000).toISOString(),
|
||||
};
|
||||
} catch (e) {
|
||||
// Silent fail - just show truncated token
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
token: token.substring(0, 50) + '...',
|
||||
fullToken: token, // Include full token for testing
|
||||
expiresOn: expiresOnISO,
|
||||
isValid: backendAuth.graphTokenManager.isTokenValid(),
|
||||
tokenDetails: Object.keys(payload).length > 0 ? payload : 'Could not decode',
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to acquire token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { BackendAuth } from '$lib/auth/backend';
|
||||
|
||||
const backendAuth = new BackendAuth(
|
||||
{
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
},
|
||||
process.env.PB_DB
|
||||
);
|
||||
|
||||
export const POST: RequestHandler = async () => {
|
||||
try {
|
||||
backendAuth.graphTokenManager.clearCache();
|
||||
const { token, expiresOnISO } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
return json({
|
||||
success: true,
|
||||
refreshed: true,
|
||||
expiresOn: expiresOnISO,
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Refresh failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { BackendAuth } from '$lib/auth/backend';
|
||||
|
||||
const backendAuth = new BackendAuth(
|
||||
{
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
},
|
||||
process.env.PB_DB
|
||||
);
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const token = body.pbToken;
|
||||
|
||||
if (!token) {
|
||||
return json({ valid: false, error: 'No token provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValid = await backendAuth.pbValidator.validateUserToken(token);
|
||||
const user = isValid ? await backendAuth.pbValidator.getUserRecord(token) : null;
|
||||
|
||||
return json({
|
||||
valid: isValid,
|
||||
user: user ? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ valid: false, error: error instanceof Error ? error.message : 'Validation failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,16 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
// LOCKED: Always use port 3005 for Mode1Svelte service
|
||||
// 3006 = Orchestrator, 3005 = Mode service
|
||||
// https://ji-test.ccllc.pro proxies to these ports
|
||||
paths: {
|
||||
base: process.env.BASE_PATH || ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
build: {
|
||||
// Ensure asset paths are relative, not absolute
|
||||
// This fixes proxy routing issues
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: 'assets/[name]-[hash][extname]'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,313 @@
|
||||
# Mode2Svelte - Advanced SvelteKit Auth Testing Suite
|
||||
|
||||
An enhanced SvelteKit-based testing environment for advanced authentication workflows with Microsoft Azure MSAL and PocketBase. This is an extended implementation of Mode1Svelte with additional testing capabilities.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: SvelteKit 2.x with TypeScript
|
||||
- **Backend**: Node.js Adapter (production-ready)
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **Authentication**:
|
||||
- Azure MSAL Node (@azure/msal-node) - Microsoft Graph token management
|
||||
- PocketBase - User OAuth2 and token validation
|
||||
- **Build Tool**: Vite (integrated with SvelteKit)
|
||||
- **Runtime**: Node.js 18+
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Frontend Authentication (PocketBase)
|
||||
- OAuth2 login initialization with Microsoft
|
||||
- Auth state management and retrieval
|
||||
- Token refresh and validation
|
||||
- Session persistence
|
||||
- Automatic logout on invalid tokens
|
||||
|
||||
### ✅ Backend Token Management (Microsoft Graph)
|
||||
- App-only token acquisition via MSAL
|
||||
- Automatic token caching with 60-second expiration buffer
|
||||
- Token refresh on demand
|
||||
- JWT payload inspection
|
||||
|
||||
### ✅ Token Validation & Comparison
|
||||
- PocketBase user token validation
|
||||
- User record retrieval from tokens
|
||||
- Side-by-side token comparison (Graph vs PocketBase)
|
||||
- Token payload analysis
|
||||
|
||||
### ✅ Interactive Testing Dashboard
|
||||
- Real-time authentication status
|
||||
- Visual token testing interface
|
||||
- API endpoint testing
|
||||
- Response inspection
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Mode1Svelte/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ └── auth/
|
||||
│ │ ├── types.ts # TypeScript interfaces
|
||||
│ │ ├── backend.ts # Backend auth classes (MSAL, PocketBase)
|
||||
│ │ └── frontend.ts # Frontend PocketBase auth class
|
||||
│ ├── routes/
|
||||
│ │ ├── +layout.svelte # App layout
|
||||
│ │ ├── +page.svelte # Main dashboard
|
||||
│ │ └── api/
|
||||
│ │ └── auth/
|
||||
│ │ ├── config/ # GET /api/auth/config
|
||||
│ │ ├── graph/ # GET /api/auth/graph/token
|
||||
│ │ ├── validate-pb-token/ # POST /api/auth/validate-pb-token
|
||||
│ │ ├── refresh-graph/ # POST /api/auth/refresh-graph
|
||||
│ │ └── compare-tokens/ # POST /api/auth/compare-tokens
|
||||
│ └── app.css # Tailwind directives
|
||||
├── static/ # Static assets
|
||||
├── package.json # Dependencies
|
||||
├── svelte.config.js # SvelteKit config (Node adapter)
|
||||
├── tailwind.config.js # Tailwind CSS config
|
||||
├── vite.config.ts # Vite config
|
||||
└── tsconfig.json # TypeScript config
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd Mode1Svelte
|
||||
npm install
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Ensure your `.env` file or environment variables are set:
|
||||
|
||||
```bash
|
||||
export CLIENT_ID="<Azure Client ID>"
|
||||
export TENANT_ID="<Azure Tenant ID>"
|
||||
export CLIENT_SECRET="<Azure Client Secret>"
|
||||
export PB_URL="<PocketBase URL>"
|
||||
export PB_DB="<PocketBase Database URL>"
|
||||
export PORT=5173
|
||||
```
|
||||
|
||||
For development, create `/home/admin/secrets/.env`:
|
||||
|
||||
```
|
||||
CLIENT_ID=your_client_id
|
||||
TENANT_ID=your_tenant_id
|
||||
CLIENT_SECRET=your_client_secret
|
||||
PB_URL=https://pocketbase.example.com
|
||||
PB_DB=https://pocketbase.example.com
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Development Mode
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Starts the dev server at `http://localhost:5173` with hot reloading.
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
Builds the application and runs the Node.js server.
|
||||
|
||||
### Type Checking
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
Run TypeScript type checking across the project.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET `/api/auth/config`
|
||||
Returns frontend-safe configuration for PocketBase OAuth setup.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"pbUrl": "https://pocketbase.example.com",
|
||||
"provider": "microsoft",
|
||||
"collection": "Users"
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/auth/graph/token`
|
||||
Fetches a Microsoft Graph token for backend API access.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJ0eXAiOiJKV1Q...",
|
||||
"fullToken": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||
"expiresOn": "2026-01-23T02:15:00.000Z",
|
||||
"isValid": true,
|
||||
"tokenDetails": {
|
||||
"aud": "https://graph.microsoft.com",
|
||||
"iss": "https://sts.windows.net/tenant-id",
|
||||
"scp": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/auth/validate-pb-token`
|
||||
Validates a PocketBase user token and retrieves user information.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"pbToken": "user_token_string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"email": "user@example.com",
|
||||
"name": "User Name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/auth/refresh-graph`
|
||||
Clears the cached Graph token and acquires a new one.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"refreshed": true,
|
||||
"expiresOn": "2026-01-23T02:15:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/auth/compare-tokens`
|
||||
Compares Graph token (backend) and PocketBase token (user) to demonstrate their differences.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"pbToken": "user_token_string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"comparison": {
|
||||
"graph_token": {
|
||||
"issuer": "https://sts.windows.net/tenant",
|
||||
"audience": "https://graph.microsoft.com",
|
||||
"app": "App Name",
|
||||
"scopes": "...",
|
||||
"issued_at": "2026-01-23T01:15:00.000Z",
|
||||
"expires_at": "2026-01-23T02:15:00.000Z",
|
||||
"type": "Microsoft Graph Token (Backend/App-only)",
|
||||
"source": "@azure/msal-node",
|
||||
"purpose": "Backend API calls to Microsoft Graph"
|
||||
},
|
||||
"pocketbase_token": {
|
||||
"type": "PocketBase User Token (Frontend/User)",
|
||||
"source": "PocketBase OAuth2 flow",
|
||||
"authenticated_user": "user@example.com"
|
||||
},
|
||||
"are_different": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Frontend (Browser)
|
||||
1. User clicks "Login with Microsoft"
|
||||
2. PocketBase initiates OAuth2 flow with Microsoft provider
|
||||
3. Microsoft redirects back with authorization code
|
||||
4. PocketBase exchanges code for user token
|
||||
5. Token stored in PocketBase auth store
|
||||
6. User dashboard updates with authenticated state
|
||||
|
||||
### Backend (Server)
|
||||
1. Application starts with MSAL configuration
|
||||
2. On first API request, acquires Graph token using client credentials
|
||||
3. Token cached for 55 minutes (60-minute lifetime - 5-minute buffer)
|
||||
4. Subsequent requests use cached token
|
||||
5. Token automatically refreshed when expired
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **CORS**: Configured for `localhost:5173` and production URLs
|
||||
- **Token Caching**: Graph tokens cached in memory; refresh on demand with `/api/auth/refresh-graph`
|
||||
- **Error Handling**: Comprehensive error messages with specific failure reasons
|
||||
- **Type Safety**: Full TypeScript support throughout frontend and backend
|
||||
- **SvelteKit Routing**: File-based routing with `+page.svelte` and `+server.ts` files
|
||||
|
||||
## Comparison with Mode1 (Hono/Bun)
|
||||
|
||||
| Feature | Mode1Svelte | Mode1 |
|
||||
|---------|-------------|--------|
|
||||
| Framework | SvelteKit 2 | SvelteKit 4 (old) |
|
||||
| Backend | Node.js Adapter | Hono + Bun |
|
||||
| Frontend | Svelte Components | HTML + Vanilla JS |
|
||||
| Styling | Tailwind CSS | CSS (custom) |
|
||||
| Build Tool | Vite | Vite |
|
||||
| API Routes | `/routes/api/` | `/routes/api/` |
|
||||
| Auth Module | `/lib/auth/` | `/routes/api/` |
|
||||
| Type Safety | Full TypeScript | TypeScript (partial) |
|
||||
| Production Ready | ✅ Yes | ⚠️ Experimental |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token Acquisition Fails
|
||||
- Verify `CLIENT_ID`, `TENANT_ID`, and `CLIENT_SECRET` are correct
|
||||
- Ensure the Azure application is configured for client credentials flow
|
||||
- Check that the application has Graph API permissions
|
||||
|
||||
### PocketBase Token Validation Fails
|
||||
- Verify `PB_URL` and `PB_DB` are correct
|
||||
- Ensure the user is properly logged in via OAuth
|
||||
- Check that PocketBase is running and accessible
|
||||
|
||||
### Login Button Not Appearing
|
||||
- Ensure JavaScript is enabled
|
||||
- Check browser console for errors
|
||||
- Verify that `loginBtn` HTML element ID matches configuration
|
||||
|
||||
## Links
|
||||
|
||||
- [SvelteKit Documentation](https://svelte.dev/docs/kit)
|
||||
- [Azure MSAL Node](https://github.com/AzureAD/microsoft-authentication-library-for-js)
|
||||
- [PocketBase Documentation](https://pocketbase.io/docs/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
|
||||
## License
|
||||
|
||||
Private - Job Info Testing Suite
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "mode2svelte",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3005",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "PORT=3005 node build/index.js",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.0.2",
|
||||
"@types/node": "^25.0.10",
|
||||
"dotenv": "^17.2.3",
|
||||
"pocketbase": "^0.26.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#1e293b" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,204 @@
|
||||
import { config } from 'dotenv';
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node';
|
||||
import PocketBase from 'pocketbase';
|
||||
import type { GraphTokenCache, BackendAuthConfig } from './types';
|
||||
|
||||
// Load environment variables from shared secrets directory
|
||||
config({ path: '/home/admin/secrets/.env' });
|
||||
|
||||
/**
|
||||
* Configuration Manager
|
||||
* Exposes frontend-safe configuration loaded from environment
|
||||
*/
|
||||
export class AuthConfigManager {
|
||||
/**
|
||||
* Get frontend configuration (safe to expose to browser)
|
||||
*/
|
||||
static getFrontendConfig() {
|
||||
return {
|
||||
pbUrl: process.env.PB_URL!,
|
||||
provider: 'microsoft',
|
||||
collection: 'Users',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all backend secrets (never expose to frontend)
|
||||
*/
|
||||
static getBackendConfig() {
|
||||
return {
|
||||
clientId: process.env.CLIENT_ID!,
|
||||
tenantId: process.env.TENANT_ID!,
|
||||
clientSecret: process.env.CLIENT_SECRET!,
|
||||
pbDb: process.env.PB_DB!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft Graph Token Management (Backend)
|
||||
* Handles token acquisition and caching for backend Graph API calls
|
||||
*/
|
||||
export class GraphTokenManager {
|
||||
private cca: ConfidentialClientApplication;
|
||||
private cache: GraphTokenCache | null = null;
|
||||
private config: Required<BackendAuthConfig>;
|
||||
|
||||
constructor(config: BackendAuthConfig) {
|
||||
this.config = {
|
||||
clientId: config.clientId || process.env.CLIENT_ID || '',
|
||||
tenantId: config.tenantId || process.env.TENANT_ID || '',
|
||||
clientSecret: config.clientSecret || process.env.CLIENT_SECRET || '',
|
||||
};
|
||||
|
||||
this.cca = new ConfidentialClientApplication({
|
||||
auth: {
|
||||
clientId: this.config.clientId,
|
||||
authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
|
||||
clientSecret: this.config.clientSecret,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Graph token (from cache or acquire new)
|
||||
*/
|
||||
async getToken(): Promise<string> {
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache validity (with 60s buffer for expiration)
|
||||
if (this.cache && this.cache.expiresOn - 60000 > now) {
|
||||
return this.cache.token;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.cca.acquireTokenByClientCredential({
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
});
|
||||
|
||||
if (!result?.accessToken) {
|
||||
throw new Error('Failed to acquire Graph token');
|
||||
}
|
||||
|
||||
const expiresOn = result.expiresOn
|
||||
? new Date(result.expiresOn).getTime()
|
||||
: now + 55 * 60 * 1000; // Default 55 minutes
|
||||
|
||||
this.cache = {
|
||||
token: result.accessToken,
|
||||
expiresOn,
|
||||
};
|
||||
|
||||
return result.accessToken;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to acquire Graph token: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token with expiration info
|
||||
*/
|
||||
async getTokenWithExpiry(): Promise<{ token: string; expiresOnISO: string }> {
|
||||
const token = await this.getToken();
|
||||
const expiresOn = this.cache?.expiresOn || Date.now();
|
||||
return {
|
||||
token,
|
||||
expiresOnISO: new Date(expiresOn).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is cached and valid
|
||||
*/
|
||||
isTokenValid(): boolean {
|
||||
if (!this.cache) return false;
|
||||
const now = Date.now();
|
||||
return this.cache.expiresOn - 60000 > now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache (force refresh on next call)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PocketBase Token Validation (Backend)
|
||||
* Validates and uses user PocketBase tokens
|
||||
*/
|
||||
export class PocketBaseValidator {
|
||||
private pb: PocketBase;
|
||||
|
||||
constructor(pbUrl?: string) {
|
||||
this.pb = new PocketBase(pbUrl || process.env.PB_DB!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user token and validate it
|
||||
*/
|
||||
async validateUserToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
this.pb.authStore.save(token, null);
|
||||
await this.pb.collection('Users').authRefresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Token validation failed:', error instanceof Error ? error.message : error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user record from token
|
||||
*/
|
||||
async getUserRecord(token: string): Promise<any> {
|
||||
try {
|
||||
this.pb.authStore.save(token, null);
|
||||
const record = this.pb.authStore.record || this.pb.authStore.model;
|
||||
return record;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user record:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PocketBase instance
|
||||
*/
|
||||
getPocketBase(): PocketBase {
|
||||
return this.pb;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined backend auth manager
|
||||
*/
|
||||
export class BackendAuth {
|
||||
graphTokenManager: GraphTokenManager;
|
||||
pbValidator: PocketBaseValidator;
|
||||
|
||||
constructor(config: BackendAuthConfig, pbUrl?: string) {
|
||||
this.graphTokenManager = new GraphTokenManager(config);
|
||||
this.pbValidator = new PocketBaseValidator(pbUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate PocketBase token in requests
|
||||
* Usage: app.use('/*', (c, next) => backendAuth.validateTokenMiddleware(c, next))
|
||||
*/
|
||||
async validateTokenMiddleware(c: any, next: any): Promise<any> {
|
||||
const token = c.req.header('Authorization')?.replace('Bearer ', '') ||
|
||||
(await c.req.json().catch(() => ({})))?.pbToken;
|
||||
|
||||
if (token) {
|
||||
const isValid = await this.pbValidator.validateUserToken(token);
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid token' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import type { AuthConfig, AuthState, AuthCallbacks } from './types';
|
||||
|
||||
/**
|
||||
* PocketBase OAuth2 Frontend Module
|
||||
* Handles user authentication and token management
|
||||
*/
|
||||
export class PocketBaseAuth {
|
||||
private pb: PocketBase;
|
||||
private config: Required<AuthConfig>;
|
||||
private callbacks: AuthCallbacks;
|
||||
private state: AuthState;
|
||||
|
||||
constructor(config: AuthConfig, callbacks?: Partial<AuthCallbacks>) {
|
||||
this.config = {
|
||||
pbUrl: config.pbUrl!,
|
||||
collection: config.collection || 'Users',
|
||||
provider: config.provider || 'microsoft',
|
||||
loginContainerId: config.loginContainerId || 'loginContainer',
|
||||
userDisplayNameId: config.userDisplayNameId || 'userDisplayName',
|
||||
userEmailId: config.userEmailId || 'userEmailValue',
|
||||
loginBtnId: config.loginBtnId || 'loginBtn',
|
||||
loginErrorId: config.loginErrorId || 'loginError',
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
onAuthSuccess: callbacks?.onAuthSuccess || (() => {}),
|
||||
onAuthFailure: callbacks?.onAuthFailure || (() => {}),
|
||||
onTokenUpdate: callbacks?.onTokenUpdate || (() => {}),
|
||||
onUiUpdate: callbacks?.onUiUpdate || (() => {}),
|
||||
};
|
||||
|
||||
this.pb = new PocketBase(this.config.pbUrl);
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth module
|
||||
*/
|
||||
private init(): void {
|
||||
this.updateAuthUI();
|
||||
if (this.pb.authStore.isValid && this.pb.authStore.token) {
|
||||
this.ensureUserLogged();
|
||||
}
|
||||
this.setupLoginButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup login button click handler
|
||||
*/
|
||||
private setupLoginButton(): void {
|
||||
console.log('[Auth] Setting up login button with id:', this.config.loginBtnId);
|
||||
|
||||
// Retry finding button in case component hasn't rendered yet
|
||||
let attempts = 0;
|
||||
const setupInterval = setInterval(() => {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId);
|
||||
console.log(`[Auth] Attempt ${attempts + 1}: Looking for button, found:`, !!loginBtn);
|
||||
|
||||
if (loginBtn) {
|
||||
clearInterval(setupInterval);
|
||||
// Remove any existing listeners to avoid duplicates
|
||||
const newBtn = loginBtn.cloneNode(true) as HTMLElement;
|
||||
loginBtn.parentNode?.replaceChild(newBtn, loginBtn);
|
||||
|
||||
newBtn.addEventListener('click', async (e) => {
|
||||
console.log('[Auth] Login button clicked!');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await this.handleLogin();
|
||||
}, false);
|
||||
console.log(`✓ Login button listener attached (attempt ${attempts + 1})`);
|
||||
} else if (attempts++ > 100) {
|
||||
// Give up after 10 seconds (100 * 100ms)
|
||||
clearInterval(setupInterval);
|
||||
console.error(`Login button with id "${this.config.loginBtnId}" not found after 10 seconds. All elements:`,
|
||||
Array.from(document.querySelectorAll('[id*="login"]')).map(e => e.id));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login button click
|
||||
*/
|
||||
private async handleLogin(): Promise<void> {
|
||||
console.log('[Auth] handleLogin called');
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
|
||||
const loginError = document.getElementById(this.config.loginErrorId) as HTMLElement;
|
||||
|
||||
if (!loginBtn) {
|
||||
console.error('[Auth] Login button not found in handleLogin!');
|
||||
return;
|
||||
}
|
||||
if (!loginError) {
|
||||
console.error('[Auth] Login error element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Auth] Starting login flow...');
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Checking session...';
|
||||
loginError.classList.add('hidden');
|
||||
|
||||
try {
|
||||
// Try to use existing token first
|
||||
console.log('[Auth] Checking for existing token...');
|
||||
const hadToken = await this.ensureUserLogged();
|
||||
if (hadToken) {
|
||||
console.log('[Auth] Found valid existing token, using it');
|
||||
this.resetLoginButton();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise perform OAuth
|
||||
console.log('[Auth] No valid token, starting OAuth flow...');
|
||||
loginBtn.textContent = 'Logging in...';
|
||||
console.log('[Auth] Calling authWithOAuth2 with provider:', this.config.provider);
|
||||
const authData = await this.pb.collection(this.config.collection).authWithOAuth2({
|
||||
provider: this.config.provider,
|
||||
});
|
||||
|
||||
console.log('[Auth] OAuth successful, updating state');
|
||||
this.updateState(authData);
|
||||
this.updateAuthUI();
|
||||
this.callbacks.onAuthSuccess?.(this.state);
|
||||
|
||||
console.log('✓ Logged in with', this.config.provider);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[Auth] Login failed:', message, error);
|
||||
loginError.textContent = `Login failed: ${message}`;
|
||||
loginError.classList.remove('hidden');
|
||||
this.callbacks.onAuthFailure?.(error);
|
||||
} finally {
|
||||
this.resetLoginButton();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user is logged in (check existing token)
|
||||
*/
|
||||
async ensureUserLogged(): Promise<boolean> {
|
||||
if (!this.pb.authStore.isValid || !this.pb.authStore.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const refresh = await this.pb.collection(this.config.collection).authRefresh();
|
||||
this.updateState(refresh);
|
||||
this.updateAuthUI();
|
||||
this.callbacks.onTokenUpdate?.(this.state);
|
||||
console.log('✓ Token refreshed');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Existing token invalid, clearing auth');
|
||||
this.pb.authStore.clear();
|
||||
this.updateState(null);
|
||||
this.updateAuthUI();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update internal auth state
|
||||
*/
|
||||
private updateState(data: any): void {
|
||||
if (!data) {
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const record = data.record || this.pb.authStore.record || this.pb.authStore.model;
|
||||
const meta = data.meta || {};
|
||||
const model = this.pb.authStore.model;
|
||||
|
||||
this.state = {
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
id: record?.id || model?.id || 'unknown',
|
||||
name: record?.name || model?.name || meta?.name || record?.email || model?.email || 'Unknown User',
|
||||
email: record?.email || model?.email || meta?.email || '(no email)',
|
||||
},
|
||||
token: this.pb.authStore.token,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI based on auth state
|
||||
*/
|
||||
updateAuthUI(): void {
|
||||
const loginContainer = document.getElementById(this.config.loginContainerId);
|
||||
const userDisplayName = document.getElementById(this.config.userDisplayNameId);
|
||||
const userEmail = document.getElementById(this.config.userEmailId);
|
||||
|
||||
if (!loginContainer) return;
|
||||
|
||||
if (this.pb.authStore.isValid) {
|
||||
loginContainer.classList.add('hidden');
|
||||
if (this.state.user) {
|
||||
if (userDisplayName) userDisplayName.textContent = this.state.user.name;
|
||||
if (userEmail) userEmail.textContent = this.state.user.email;
|
||||
}
|
||||
} else {
|
||||
loginContainer.classList.remove('hidden');
|
||||
const loginError = document.getElementById(this.config.loginErrorId);
|
||||
if (loginError) loginError.classList.add('hidden');
|
||||
}
|
||||
|
||||
this.callbacks.onUiUpdate?.(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check token status (for verification/display)
|
||||
*/
|
||||
async checkTokenStatus(): Promise<{ pbToken: boolean; tokenExpiry?: string }> {
|
||||
const pbToken = !!this.pb.authStore.token;
|
||||
return { pbToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auth state
|
||||
*/
|
||||
getAuthState(): AuthState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PocketBase instance (for direct usage if needed)
|
||||
*/
|
||||
getPocketBase(): PocketBase {
|
||||
return this.pb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
logout(): void {
|
||||
this.pb.authStore.clear();
|
||||
this.updateState(null);
|
||||
this.updateAuthUI();
|
||||
console.log('✓ Logged out');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset login button to initial state
|
||||
*/
|
||||
private resetLoginButton(): void {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
|
||||
if (loginBtn) {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Login with Microsoft';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick init function - fetches config from backend
|
||||
*/
|
||||
export async function initPocketBaseAuth(
|
||||
backendUrl: string,
|
||||
callbacks?: Partial<AuthCallbacks>
|
||||
): Promise<PocketBaseAuth> {
|
||||
// Fetch frontend config from backend
|
||||
const response = await fetch(`${backendUrl}/api/auth/config`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch auth configuration from backend');
|
||||
}
|
||||
const config = await response.json();
|
||||
|
||||
const auth = new PocketBaseAuth(config, callbacks);
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Frontend Auth Configuration
|
||||
*/
|
||||
export interface AuthConfig {
|
||||
pbUrl: string; // PocketBase URL (required)
|
||||
collection?: string;
|
||||
provider?: string;
|
||||
loginContainerId?: string;
|
||||
userDisplayNameId?: string;
|
||||
userEmailId?: string;
|
||||
loginBtnId?: string;
|
||||
loginErrorId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend configuration provided by backend
|
||||
*/
|
||||
export interface FrontendConfig {
|
||||
pbUrl: string;
|
||||
provider?: string;
|
||||
collection?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend Auth Configuration
|
||||
*/
|
||||
export interface BackendAuthConfig {
|
||||
clientId?: string;
|
||||
tenantId?: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state object
|
||||
*/
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
} | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph token cache object
|
||||
*/
|
||||
export interface GraphTokenCache {
|
||||
token: string;
|
||||
expiresOn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth event callbacks
|
||||
*/
|
||||
export interface AuthCallbacks {
|
||||
onAuthSuccess?: (state: AuthState) => void;
|
||||
onAuthFailure?: (error: any) => void;
|
||||
onTokenUpdate?: (state: AuthState) => void;
|
||||
onUiUpdate?: (state: AuthState) => void;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { BackendAuth } from '$lib/auth/backend';
|
||||
|
||||
const backendAuth = new BackendAuth(
|
||||
{
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
},
|
||||
process.env.PB_DB
|
||||
);
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const pbToken = body.pbToken;
|
||||
|
||||
// Get Graph token
|
||||
const { token: graphToken, expiresOnISO: graphExpires } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
|
||||
if (!graphToken) {
|
||||
return json({ success: false, error: 'Failed to acquire Graph token' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Decode both tokens to compare
|
||||
const decodeJWT = (token: string) => {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
return JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const graphPayload = decodeJWT(graphToken);
|
||||
|
||||
// Validate PB token to get actual user
|
||||
let authenticatedUser = 'Unknown';
|
||||
if (pbToken) {
|
||||
try {
|
||||
const isValid = await backendAuth.pbValidator.validateUserToken(pbToken);
|
||||
if (isValid) {
|
||||
const userRecord = await backendAuth.pbValidator.getUserRecord(pbToken);
|
||||
authenticatedUser = userRecord?.email || userRecord?.id || 'Unknown';
|
||||
}
|
||||
} catch {
|
||||
authenticatedUser = 'Token invalid or expired';
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
comparison: {
|
||||
graph_token: {
|
||||
issuer: graphPayload?.iss || 'N/A',
|
||||
audience: graphPayload?.aud || 'N/A',
|
||||
app: graphPayload?.app_displayname || 'N/A',
|
||||
scopes: graphPayload?.scp || 'N/A',
|
||||
issued_at: graphPayload?.iat ? new Date(graphPayload.iat * 1000).toISOString() : 'N/A',
|
||||
expires_at: graphPayload?.exp ? new Date(graphPayload.exp * 1000).toISOString() : 'N/A',
|
||||
type: 'Microsoft Graph Token (Backend/App-only)',
|
||||
source: '@azure/msal-node',
|
||||
purpose: 'Backend API calls to Microsoft Graph',
|
||||
},
|
||||
pocketbase_token: {
|
||||
note: 'User token from PocketBase OAuth2',
|
||||
type: 'PocketBase User Token (Frontend/User)',
|
||||
source: 'PocketBase OAuth2 flow',
|
||||
purpose: 'User authentication and authorization',
|
||||
authenticated_user: authenticatedUser,
|
||||
},
|
||||
are_different: true,
|
||||
explanation: 'These are completely different tokens from different systems: Graph token is for app-to-app Microsoft API access, PocketBase token is for user authentication in the application.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to compare tokens' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { AuthConfigManager } from '$lib/auth/backend';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = AuthConfigManager.getFrontendConfig();
|
||||
return json(config);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: 'Failed to get config', message: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { BackendAuth, AuthConfigManager } from '$lib/auth/backend';
|
||||
|
||||
const backendAuth = new BackendAuth(
|
||||
{
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
},
|
||||
process.env.PB_DB
|
||||
);
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const { token, expiresOnISO } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
|
||||
if (!token) {
|
||||
return json({ success: false, error: 'Failed to acquire Graph token' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Decode JWT to show token details (for verification)
|
||||
const parts = token.split('.');
|
||||
let payload: any = {};
|
||||
if (parts.length === 3) {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
payload = {
|
||||
aud: decoded.aud,
|
||||
iss: decoded.iss,
|
||||
scp: decoded.scp,
|
||||
app_displayname: decoded.app_displayname,
|
||||
iat: new Date(decoded.iat * 1000).toISOString(),
|
||||
exp: new Date(decoded.exp * 1000).toISOString(),
|
||||
};
|
||||
} catch (e) {
|
||||
// Silent fail - just show truncated token
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
token: token.substring(0, 50) + '...',
|
||||
fullToken: token, // Include full token for testing
|
||||
expiresOn: expiresOnISO,
|
||||
isValid: backendAuth.graphTokenManager.isTokenValid(),
|
||||
tokenDetails: Object.keys(payload).length > 0 ? payload : 'Could not decode',
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to acquire token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { BackendAuth } from '$lib/auth/backend';
|
||||
|
||||
const backendAuth = new BackendAuth(
|
||||
{
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
},
|
||||
process.env.PB_DB
|
||||
);
|
||||
|
||||
export const POST: RequestHandler = async () => {
|
||||
try {
|
||||
backendAuth.graphTokenManager.clearCache();
|
||||
const { token, expiresOnISO } = await backendAuth.graphTokenManager.getTokenWithExpiry();
|
||||
return json({
|
||||
success: true,
|
||||
refreshed: true,
|
||||
expiresOn: expiresOnISO,
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Refresh failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { BackendAuth } from '$lib/auth/backend';
|
||||
|
||||
const backendAuth = new BackendAuth(
|
||||
{
|
||||
clientId: process.env.CLIENT_ID,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
},
|
||||
process.env.PB_DB
|
||||
);
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const token = body.pbToken;
|
||||
|
||||
if (!token) {
|
||||
return json({ valid: false, error: 'No token provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValid = await backendAuth.pbValidator.validateUserToken(token);
|
||||
const user = isValid ? await backendAuth.pbValidator.getUserRecord(token) : null;
|
||||
|
||||
return json({
|
||||
valid: isValid,
|
||||
user: user ? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ valid: false, error: error instanceof Error ? error.message : 'Validation failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Proxy endpoint to fetch file content from SharePoint
|
||||
* Bypasses CORS restrictions by using backend authentication
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
try {
|
||||
// Get Graph token from request header or environment
|
||||
const headerToken = request.headers.get('x-graph-token') || '';
|
||||
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
|
||||
const token = headerToken || envToken;
|
||||
|
||||
if (!token) {
|
||||
return json({ error: 'GRAPH_TOKEN missing' }, { status: 500 });
|
||||
}
|
||||
|
||||
const driveId = url.searchParams.get('driveId');
|
||||
const itemId = url.searchParams.get('itemId');
|
||||
|
||||
if (!driveId || !itemId) {
|
||||
return json({ error: 'driveId and itemId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch from Microsoft Graph API
|
||||
const graphUrl = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
|
||||
const res = await fetch(graphUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[file-content] Graph API error: ${res.status} - ${text}`);
|
||||
return json({ error: `Graph ${res.status}: ${text}` }, { status: 502 });
|
||||
}
|
||||
|
||||
// Get the content type and disposition headers
|
||||
const contentType = res.headers.get('content-type') || 'application/octet-stream';
|
||||
const contentDisposition = res.headers.get('content-disposition') || '';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
};
|
||||
|
||||
if (contentDisposition) {
|
||||
headers['Content-Disposition'] = contentDisposition;
|
||||
}
|
||||
|
||||
// Return the file as a response
|
||||
const buffer = await res.arrayBuffer();
|
||||
return new Response(buffer, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[file-content] Error:', message);
|
||||
return json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Proxy endpoint to fetch and convert Word documents to PDF
|
||||
* Uses Microsoft Graph API with ?format=pdf parameter
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
try {
|
||||
// Get Graph token from request header or environment
|
||||
const headerToken = request.headers.get('x-graph-token') || '';
|
||||
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
|
||||
const token = headerToken || envToken;
|
||||
|
||||
if (!token) {
|
||||
return json({ error: 'GRAPH_TOKEN missing' }, { status: 500 });
|
||||
}
|
||||
|
||||
const driveId = url.searchParams.get('driveId');
|
||||
const itemId = url.searchParams.get('itemId');
|
||||
|
||||
if (!driveId || !itemId) {
|
||||
return json({ error: 'driveId and itemId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use Graph API to convert to PDF format
|
||||
const graphUrl = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`;
|
||||
const res = await fetch(graphUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[file-pdf] Conversion failed: ${res.status} - ${text}`);
|
||||
return json({ error: `PDF conversion failed: ${res.status}` }, { status: 502 });
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
};
|
||||
|
||||
// Update filename to .pdf if present
|
||||
const contentDisposition = res.headers.get('content-disposition');
|
||||
if (contentDisposition) {
|
||||
headers['Content-Disposition'] = contentDisposition.replace(/\.(docx?)/gi, '.pdf');
|
||||
}
|
||||
|
||||
console.log(`[file-pdf] Successfully converted: driveId=${driveId}, itemId=${itemId}`);
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
return new Response(buffer, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[file-pdf] Error:', message);
|
||||
return json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
/**
|
||||
* Helper to encode SharePoint links for Graph API /shares endpoint
|
||||
*/
|
||||
function b64Url(input: string) {
|
||||
return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to fetch JSON from Graph API
|
||||
*/
|
||||
async function fetchJson(url: string, token: string) {
|
||||
console.log('📁 Fetching:', url);
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err: any = new Error(`Graph ${res.status}: ${text}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a SharePoint folder link to driveId and itemId
|
||||
* Handles both short sharing links and direct document library URLs
|
||||
* Works with both RootFolder parameter format and direct path format
|
||||
*/
|
||||
const resolveShareLink = async (link: string, token: string) => {
|
||||
// Decode the link in case it comes pre-encoded from the database
|
||||
const decodedLink = decodeURIComponent(link);
|
||||
console.log('[resolveShareLink] Decoded link:', decodedLink.substring(0, 150));
|
||||
|
||||
// Check if this is a short sharing link (/:f:/ or /:b:/)
|
||||
if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) {
|
||||
// Short sharing link - use shares API
|
||||
const encoded = `u!${b64Url(decodedLink)}`;
|
||||
console.log('[resolveShareLink] Using shares API, encoded:', encoded);
|
||||
const data = await fetchJson(`${GRAPH_BASE}/shares/${encoded}/driveItem`, token);
|
||||
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
|
||||
} else {
|
||||
// Direct document library URL - parse it and use drive API
|
||||
const url = new URL(decodedLink);
|
||||
const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/);
|
||||
if (!pathMatch) throw new Error('Invalid SharePoint URL');
|
||||
|
||||
const hostname = pathMatch[1]; // e.g., 'czflex'
|
||||
|
||||
// Decode pathname to handle spaces properly
|
||||
const decodedPathname = decodeURIComponent(url.pathname);
|
||||
console.log('[resolveShareLink] Decoded pathname:', decodedPathname);
|
||||
|
||||
const sitePath = decodedPathname.split('/Shared Documents')[0]; // e.g., /sites/Team
|
||||
let folderPath: string | null = null;
|
||||
|
||||
// Try RootFolder parameter first (AllItems.aspx format)
|
||||
const rootFolderParam = url.searchParams.get('RootFolder');
|
||||
if (rootFolderParam) {
|
||||
console.log('[resolveShareLink] Found RootFolder parameter');
|
||||
// RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/...
|
||||
// We need: General/Operations [Server]/...
|
||||
folderPath = rootFolderParam.split('/Shared Documents/')[1];
|
||||
if (!folderPath) throw new Error('Could not parse folder path from RootFolder');
|
||||
} else {
|
||||
// No RootFolder - extract from pathname directly
|
||||
// Format: /sites/Team/Shared Documents/General/Operations [Server]/1 Job Pages/...
|
||||
console.log('[resolveShareLink] No RootFolder, parsing from pathname');
|
||||
const sharedDocsIndex = decodedPathname.indexOf('/Shared Documents/');
|
||||
if (sharedDocsIndex !== -1) {
|
||||
const pathAfterSharedDocs = decodedPathname.substring(sharedDocsIndex + '/Shared Documents/'.length);
|
||||
folderPath = pathAfterSharedDocs;
|
||||
console.log('[resolveShareLink] Extracted folderPath from pathname:', folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!folderPath) {
|
||||
throw new Error('Could not extract folder path from URL');
|
||||
}
|
||||
|
||||
console.log('[resolveShareLink] Hostname:', hostname);
|
||||
console.log('[resolveShareLink] Site path:', sitePath);
|
||||
console.log('[resolveShareLink] Folder path:', folderPath);
|
||||
|
||||
// Get the site ID first using hostname:sitePath format
|
||||
const siteData = await fetchJson(`${GRAPH_BASE}/sites/${hostname}.sharepoint.com:${sitePath}`, token);
|
||||
const siteId = siteData.id;
|
||||
console.log('[resolveShareLink] Site ID:', siteId);
|
||||
|
||||
// Get the default document library drive
|
||||
const driveData = await fetchJson(`${GRAPH_BASE}/sites/${siteId}/drive`, token);
|
||||
const driveId = driveData.id;
|
||||
console.log('[resolveShareLink] Drive ID:', driveId);
|
||||
|
||||
// Get the folder item by path - need to properly encode the path
|
||||
const encodedPath = folderPath
|
||||
.split('/')
|
||||
.map(segment => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
console.log('[resolveShareLink] Encoded path for Graph API:', encodedPath);
|
||||
|
||||
const itemData = await fetchJson(
|
||||
`${GRAPH_BASE}/drives/${driveId}/root:/${encodedPath}`,
|
||||
token
|
||||
);
|
||||
|
||||
console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id);
|
||||
return { driveId, itemId: itemData.id };
|
||||
}
|
||||
};
|
||||
|
||||
async function listChildrenRecursive(
|
||||
driveId: string,
|
||||
itemId: string,
|
||||
depth = 0,
|
||||
maxDepth = 5,
|
||||
token: string,
|
||||
): Promise<any[]> {
|
||||
const out: any[] = [];
|
||||
const data = await fetchJson(`${GRAPH_BASE}/drives/${driveId}/items/${itemId}/children`, token);
|
||||
for (const child of data.value || []) {
|
||||
out.push(child);
|
||||
if (child.folder && depth < maxDepth) {
|
||||
const nested = await listChildrenRecursive(driveId, child.id, depth + 1, maxDepth, token);
|
||||
out.push(...nested);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterByCategory(items: any[], category?: string, pdfOnly: boolean = true) {
|
||||
// First filter: show PDF files and Word documents (which will be converted to PDF)
|
||||
let filtered = items;
|
||||
if (pdfOnly) {
|
||||
filtered = items.filter((i) => {
|
||||
if (i.folder) return false;
|
||||
const name = i.name.toLowerCase();
|
||||
const mimeType = i.file?.mimeType?.toLowerCase() || '';
|
||||
// Include PDFs, Word documents, and images
|
||||
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
|
||||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
|
||||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
|
||||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
|
||||
name.endsWith('.webp') || mimeType.includes('image');
|
||||
});
|
||||
}
|
||||
|
||||
// Second filter: category filtering (if specified)
|
||||
if (!category) return filtered;
|
||||
const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w));
|
||||
const lc = (s: string) => (s || '').toLowerCase();
|
||||
const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'];
|
||||
const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'];
|
||||
return filtered.filter((i) => {
|
||||
const n = lc(i.name);
|
||||
if (category === 'contracts') return nameHas(n, contractWords);
|
||||
if (category === 'plans') return nameHas(n, planWords);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export const GET: RequestHandler = async ({ url, request }) => {
|
||||
try {
|
||||
const link = url.searchParams.get('link');
|
||||
const category = url.searchParams.get('category');
|
||||
|
||||
if (!link) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'link required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get Graph token - try header first, then fall back to environment variable
|
||||
const headerToken = request.headers.get('x-graph-token') || '';
|
||||
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
|
||||
const token = headerToken || envToken;
|
||||
|
||||
console.log('[job-files] Header token:', headerToken ? headerToken.substring(0, 20) + '...' : 'NONE');
|
||||
console.log('[job-files] Env token:', envToken ? 'SET' : 'NOT SET');
|
||||
console.log('[job-files] Using token:', token ? token.substring(0, 20) + '...' : 'NONE');
|
||||
|
||||
if (!token) {
|
||||
console.log('[job-files] No token found');
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'GRAPH_TOKEN missing' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[job-files] Link:', link);
|
||||
const { driveId, itemId } = await resolveShareLink(link, token);
|
||||
|
||||
// Walk subfolders up to depth 5
|
||||
let items: any[] = [];
|
||||
items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
|
||||
|
||||
// Filter to show only PDFs by default (can be disabled with pdfOnly=false query param)
|
||||
const pdfOnly = url.searchParams.get('pdfOnly') !== 'false';
|
||||
const filtered = filterByCategory(items, category, pdfOnly);
|
||||
console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`);
|
||||
|
||||
const mapped = filtered.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
url: i.webUrl,
|
||||
driveId: i.parentReference?.driveId || driveId,
|
||||
size: i.size,
|
||||
modified: i.lastModifiedDateTime,
|
||||
contentType: i.file?.mimeType,
|
||||
isFolder: Boolean(i.folder),
|
||||
path: i.parentReference?.path || '',
|
||||
}));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ items: mapped, total: mapped.length, source: 'walk', driveId }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = (err as Error)?.message || String(err);
|
||||
const status = (err as any)?.status || 500;
|
||||
console.error('[job-files] ERROR:', message);
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: status, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const PB_DB = process.env.PB_DB || 'https://pocketbase.ccllc.pro';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
const pb = new PocketBase(PB_DB);
|
||||
const record = await pb.collection('pbc_1407612689').getOne(params.id!);
|
||||
|
||||
return new Response(JSON.stringify(record), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Job not found',
|
||||
}),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
try {
|
||||
const pb = new PocketBase(PB_DB);
|
||||
const data = await request.json();
|
||||
|
||||
const updated = await pb.collection('pbc_1407612689').update(params.id!, data);
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Failed to update job',
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const PB_DB = process.env.PB_DB || 'https://pocketbase.ccllc.pro';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const pb = new PocketBase(PB_DB);
|
||||
|
||||
const page = Number(url.searchParams.get('page')) || 1;
|
||||
const perPage = Number(url.searchParams.get('perPage')) || 30;
|
||||
const sort = url.searchParams.get('sort') || '-Job_Number';
|
||||
|
||||
// Build filter query from parameters
|
||||
const filters: string[] = [];
|
||||
const division = url.searchParams.get('division');
|
||||
const active = url.searchParams.get('active');
|
||||
const estimator = url.searchParams.get('estimator');
|
||||
const status = url.searchParams.get('status');
|
||||
const search = url.searchParams.get('search');
|
||||
|
||||
if (division) {
|
||||
filters.push(`Job_Division = "${division}"`);
|
||||
}
|
||||
if (active) {
|
||||
filters.push(`Active = ${active === 'Yes' ? 'true' : 'false'}`);
|
||||
}
|
||||
if (estimator) {
|
||||
filters.push(`Estimator = "${estimator}"`);
|
||||
}
|
||||
if (status) {
|
||||
filters.push(`Job_Status = "${status}"`);
|
||||
}
|
||||
if (search) {
|
||||
filters.push(`(Job_Number ~ "${search}" || Job_Name ~ "${search}" || Company_Client ~ "${search}")`);
|
||||
}
|
||||
|
||||
const filterQuery = filters.length > 0 ? filters.join(' && ') : '';
|
||||
|
||||
const records = await pb.collection('pbc_1407612689').getList(page, perPage, {
|
||||
sort,
|
||||
filter: filterQuery,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(records), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch jobs',
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,16 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
// LOCKED: Always use port 3005 for Mode1Svelte service
|
||||
// 3006 = Orchestrator, 3005 = Mode service
|
||||
// https://ji-test.ccllc.pro proxies to these ports
|
||||
paths: {
|
||||
base: process.env.BASE_PATH || ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
build: {
|
||||
// Ensure asset paths are relative, not absolute
|
||||
// This fixes proxy routing issues
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: 'assets/[name]-[hash][extname]'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
# Mode3 - QuickBooks SOAP Integration
|
||||
|
||||
This mode provides a SOAP server for QuickBooks desktop integration via the QuickBooks Web Connector.
|
||||
|
||||
## Features
|
||||
|
||||
- SOAP endpoint for QuickBooks Web Connector
|
||||
- Query and update QuickBooks entities (customers, invoices, etc.)
|
||||
- Request authentication and session management
|
||||
- Change detection and queue management
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd Mode3
|
||||
bun install
|
||||
bun run backend/server.ts
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file with:
|
||||
|
||||
```
|
||||
QUICKBOOKS_USER_NAME=your_username
|
||||
QUICKBOOKS_PASSWORD=your_password
|
||||
QUICKBOOKS_FILE_PATH=/path/to/quickbooks/file
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /ws` - SOAP endpoint for QuickBooks Web Connector
|
||||
- `POST /quickbooks/auth` - Authenticate with QuickBooks
|
||||
- `GET /quickbooks/status` - Get current QB session status
|
||||
@@ -0,0 +1,664 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "job-info-mode3",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"soap": "^0.12.0",
|
||||
"tailwind": "^4.0.0",
|
||||
"xml2js": "^0.6.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/runtime": ["@babel/runtime@7.3.4", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"ajv": ["ajv@6.10.0", "", { "dependencies": { "fast-deep-equal": "^2.0.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg=="],
|
||||
|
||||
"amqplib": ["amqplib@0.5.2", "", { "dependencies": { "bitsyntax": "~0.0.4", "bluebird": "^3.4.6", "buffer-more-ints": "0.0.2", "readable-stream": "1.x >=1.1.9", "safe-buffer": "^5.0.1" } }, "sha512-l9mCs6LbydtHqRniRwYkKdqxVa6XMz3Vw1fh+2gJaaVgTM6Jk3o8RccAKWKtlhT1US5sWrFh+KKxsVUALURSIA=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||
|
||||
"app-root-path": ["app-root-path@2.1.0", "", {}, "sha512-z5BqVjscbjmJBybKlICogJR2jCr2q/Ixu7Pvui5D4y97i7FLsJlvEG9XOR/KJRlkxxZz7UaaS2TMwQh1dRJ2dA=="],
|
||||
|
||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||
|
||||
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
||||
|
||||
"array.prototype.reduce": ["array.prototype.reduce@1.0.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-array-method-boxes-properly": "^1.0.0", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "is-string": "^1.1.1" } }, "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw=="],
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"asn1": ["asn1@0.2.3", "", {}, "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w=="],
|
||||
|
||||
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"async-limiter": ["async-limiter@1.0.1", "", {}, "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="],
|
||||
|
||||
"async-retry": ["async-retry@1.2.3", "", { "dependencies": { "retry": "0.12.0" } }, "sha512-tfDb02Th6CE6pJUF2gjW5ZVjsgwlucVXOEQMvEX9JgSJMs9gAX+Nz3xRuJBKuUYjTSYORqvDBORdAQ3LU59g7Q=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.24", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"aws-sign2": ["aws-sign2@0.7.0", "", {}, "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA=="],
|
||||
|
||||
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
|
||||
|
||||
"babel-runtime": ["babel-runtime@6.26.0", "", { "dependencies": { "core-js": "^2.4.0", "regenerator-runtime": "^0.11.0" } }, "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="],
|
||||
|
||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||
|
||||
"bitsyntax": ["bitsyntax@0.0.4", "", { "dependencies": { "buffer-more-ints": "0.0.2" } }, "sha512-Pav3HSZXD2NLQOWfJldY3bpJLt8+HS2nUo5Z1bLLmHg2vCE/cM1qfEvNjlYo7GgYQPneNr715Bh42i01ZHZPvw=="],
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
|
||||
"body-parser": ["body-parser@1.18.3", "", { "dependencies": { "bytes": "3.0.0", "content-type": "~1.0.4", "debug": "2.6.9", "depd": "~1.1.2", "http-errors": "~1.6.3", "iconv-lite": "0.4.23", "on-finished": "~2.3.0", "qs": "6.5.2", "raw-body": "2.3.3", "type-is": "~1.6.16" } }, "sha512-YQyoqQG3sO8iCmf8+hyVpgHHOv0/hCEFiS4zTGUwTA1HjAFX66wRcNQrVCeJq9pgESMRvUAOvSil5MJlmccuKQ=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"buffer-more-ints": ["buffer-more-ints@0.0.2", "", {}, "sha512-PDgX2QJgUc5+Jb2xAoBFP5MxhtVUmZHR33ak+m/SDxRdCrbnX1BggRIaxiW7ImwfmO4iJeCQKN18ToSXWGjYkA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"bytes": ["bytes@3.0.0", "", {}, "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
|
||||
|
||||
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
|
||||
|
||||
"chalk": ["chalk@2.4.1", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ=="],
|
||||
|
||||
"color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||
|
||||
"color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commands-events": ["commands-events@1.0.4", "", { "dependencies": { "@babel/runtime": "7.2.0", "formats": "1.0.0", "uuidv4": "2.0.0" } }, "sha512-HdP/+1Anoc7z+6L2h7nd4Imz54+LW+BjMGt30riBZrZ3ZeP/8el93wD8Jj8ltAaqVslqNgjX6qlhSBJwuDSmpg=="],
|
||||
|
||||
"comparejs": ["comparejs@1.0.0", "", {}, "sha512-Ue/Zd9aOucHzHXwaCe4yeHR7jypp7TKrIBZ5yls35nPNiVXlW14npmNVKM1ZaLlQTKZ6/4ewA//gYKHHIwCpOw=="],
|
||||
|
||||
"compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
|
||||
|
||||
"compression": ["compression@1.7.3", "", { "dependencies": { "accepts": "~1.3.5", "bytes": "3.0.0", "compressible": "~2.0.14", "debug": "2.6.9", "on-headers": "~1.0.1", "safe-buffer": "5.1.2", "vary": "~1.1.2" } }, "sha512-HSjyBG5N1Nnz7tF2+O7A9XUhyjru71/fwgNb7oIsEVHR0WShfs2tIS/EySLgiTe98aOK18YDlMXpzjCXY/n9mg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.2", "", {}, "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA=="],
|
||||
|
||||
"content-type": ["content-type@1.0.4", "", {}, "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="],
|
||||
|
||||
"cookie": ["cookie@0.3.1", "", {}, "sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="],
|
||||
|
||||
"core-js": ["core-js@2.6.12", "", {}, "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"crypto2": ["crypto2@2.0.0", "", { "dependencies": { "babel-runtime": "6.26.0", "node-rsa": "0.4.2", "util.promisify": "1.0.0" } }, "sha512-jdXdAgdILldLOF53md25FiQ6ybj2kUFTiRjs7msKTUoZrzgT/M1FPX5dYGJjbbwFls+RJIiZxNTC02DE/8y0ZQ=="],
|
||||
|
||||
"dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
|
||||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"datasette": ["datasette@1.0.1", "", { "dependencies": { "comparejs": "1.0.0", "eventemitter2": "5.0.1", "lodash": "4.17.5" } }, "sha512-aJdlCBToEJUP4M57r67r4V6tltwGKa3qetnjpBtXYIlqbX9tM9jsoDMxb4xd9AGjpp3282oHRmqI5Z8TVAU0Mg=="],
|
||||
|
||||
"debug": ["debug@0.7.4", "", {}, "sha512-EohAb3+DSHSGx8carOSKJe8G0ayV5/i609OD0J2orCkuyae7SyZSz2aoLmQF2s0Pj5gITDebwPH7GFBlqOUQ1Q=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
|
||||
|
||||
"destroy": ["destroy@1.0.4", "", {}, "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg=="],
|
||||
|
||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"draht": ["draht@1.0.1", "", { "dependencies": { "eventemitter2": "5.0.1" } }, "sha512-yNNHL864dniNmIE9ZKD++mKypiAUAvVZtyV0QrbXH/ak3ebzFqo5xsmRBRqV8pZVhImOSBiyq500Wcmrf44zAg=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
|
||||
|
||||
"encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
|
||||
|
||||
"es-array-method-boxes-properly": ["es-array-method-boxes-properly@1.0.0", "", {}, "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventemitter2": ["eventemitter2@5.0.1", "", {}, "sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg=="],
|
||||
|
||||
"express": ["express@4.16.4", "", { "dependencies": { "accepts": "~1.3.5", "array-flatten": "1.1.1", "body-parser": "1.18.3", "content-disposition": "0.5.2", "content-type": "~1.0.4", "cookie": "0.3.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "~1.1.2", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.1.1", "fresh": "0.5.2", "merge-descriptors": "1.0.1", "methods": "~1.1.2", "on-finished": "~2.3.0", "parseurl": "~1.3.2", "path-to-regexp": "0.1.7", "proxy-addr": "~2.0.4", "qs": "6.5.2", "range-parser": "~1.2.0", "safe-buffer": "5.1.2", "send": "0.16.2", "serve-static": "1.13.2", "setprototypeof": "1.1.0", "statuses": "~1.4.0", "type-is": "~1.6.16", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.1.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.2", "statuses": "~1.4.0", "unpipe": "~1.0.0" } }, "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg=="],
|
||||
|
||||
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
|
||||
|
||||
"first-chunk-stream": ["first-chunk-stream@0.1.0", "", {}, "sha512-o7kVqimu9cl+XNeEGqDPI8Ms4IViicBnjIDZ5uU+7aegfDhJJiU1Da9y52Qt0TfBO3rpKA5hW2cqwp4EkCfl9w=="],
|
||||
|
||||
"flaschenpost": ["flaschenpost@1.1.3", "", { "dependencies": { "@babel/runtime": "7.2.0", "app-root-path": "2.1.0", "babel-runtime": "6.26.0", "chalk": "2.4.1", "find-root": "1.1.0", "lodash": "4.17.11", "moment": "2.22.2", "processenv": "1.1.0", "split2": "3.0.0", "stack-trace": "0.0.10", "stringify-object": "3.3.0", "untildify": "3.0.3", "util.promisify": "1.0.0", "varname": "2.0.3" }, "bin": { "flaschenpost-uncork": "dist/bin/flaschenpost-uncork.js", "flaschenpost-normalize": "dist/bin/flaschenpost-normalize.js" } }, "sha512-1VAYPvDsVBGFJyUrOa/6clnJwZYC3qVq9nJLcypy6lvaaNbo1wOQiH8HQ+4Fw/k51pVG7JHzSf5epb8lmIW86g=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"forever-agent": ["forever-agent@0.6.1", "", {}, "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw=="],
|
||||
|
||||
"form-data": ["form-data@2.3.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", "mime-types": "^2.1.12" } }, "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ=="],
|
||||
|
||||
"formats": ["formats@1.0.0", "", {}, "sha512-For0Y8egwEK96JgJo4NONErPhtl7H2QzeB2NYGmzeGeJ8a1JZqPgLYOtM3oJRCYhmgsdDFd6KGRYyfe37XY4Yg=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||
|
||||
"getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"har-schema": ["har-schema@2.0.0", "", {}, "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q=="],
|
||||
|
||||
"har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hase": ["hase@2.0.0", "", { "dependencies": { "@babel/runtime": "7.1.2", "amqplib": "0.5.2" } }, "sha512-L83pBR/oZvQQNjv4kw9aUpTqBxERPiY7B42jsmkt1VDeUaRVhYkEIKzkCqrppjtxHe2EZqzZJzuhMXsWsxYIsw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.12.0", "", {}, "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA=="],
|
||||
|
||||
"http-errors": ["http-errors@1.6.3", "", { "dependencies": { "depd": "~1.1.2", "inherits": "2.0.3", "setprototypeof": "1.1.0", "statuses": ">= 1.4.0 < 2" } }, "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A=="],
|
||||
|
||||
"http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.23", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.3", "", {}, "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw=="],
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
|
||||
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||
|
||||
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
|
||||
|
||||
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
|
||||
|
||||
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
|
||||
|
||||
"is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
|
||||
|
||||
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
|
||||
|
||||
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
|
||||
|
||||
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
|
||||
|
||||
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
|
||||
|
||||
"is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="],
|
||||
|
||||
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
|
||||
|
||||
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
|
||||
|
||||
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
|
||||
|
||||
"is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
|
||||
|
||||
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
|
||||
|
||||
"is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="],
|
||||
|
||||
"is-utf8": ["is-utf8@0.2.1", "", {}, "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q=="],
|
||||
|
||||
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
|
||||
|
||||
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
|
||||
|
||||
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
|
||||
|
||||
"isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="],
|
||||
|
||||
"isstream": ["isstream@0.1.2", "", {}, "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="],
|
||||
|
||||
"jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="],
|
||||
|
||||
"json-lines": ["json-lines@1.0.0", "", { "dependencies": { "timer2": "1.0.0" } }, "sha512-ytuLZb4RBQb3bTRsG/QBenyIo5oHLpjeCVph3s2NnoAsZE9K6h+uR+OWpEOWV1UeHdX63tYctGppBpGAc+JNMA=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@8.5.0", "", { "dependencies": { "jws": "^3.2.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^5.6.0" } }, "sha512-IqEycp0znWHNA11TpYi77bVgyBO/pGESDh7Ajhas+u0ttkGkKYIIAjniL4Bw5+oVejVF+SYkaI7XKfwCCyeTuA=="],
|
||||
|
||||
"jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="],
|
||||
|
||||
"jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="],
|
||||
|
||||
"jws": ["jws@3.2.3", "", { "dependencies": { "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g=="],
|
||||
|
||||
"limes": ["limes@2.0.0", "", { "dependencies": { "@babel/runtime": "7.3.4", "jsonwebtoken": "8.5.0" } }, "sha512-evWD0pnTgPX7QueaSoJl5JBUL30T1ZVzo34ke97tIKmeagqhBTYK/JkKL0vtG3MpNApw8ZY9TlbybfwEz9knBA=="],
|
||||
|
||||
"lodash": ["lodash@3.10.1", "", {}, "sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||
|
||||
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
|
||||
|
||||
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||
|
||||
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
|
||||
|
||||
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
|
||||
|
||||
"lusca": ["lusca@1.6.1", "", { "dependencies": { "tsscmp": "^1.0.5" } }, "sha512-+JzvUMH/rsE/4XfHdDOl70bip0beRcHSviYATQM0vtls59uVtdn1JMu4iD7ZShBpAmFG8EnaA+PrYG9sECMIOQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@1.0.1", "", {}, "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="],
|
||||
|
||||
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||
|
||||
"mime": ["mime@1.4.1", "", { "bin": { "mime": "cli.js" } }, "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"moment": ["moment@2.22.2", "", {}, "sha512-LRvkBHaJGnrcWvqsElsOhHCzj8mU39wLx5pQ0pc6s153GynCTsPdGdqsVNKAQD9sKnWj11iF7TZx9fpLwdD3fw=="],
|
||||
|
||||
"morgan": ["morgan@1.9.1", "", { "dependencies": { "basic-auth": "~2.0.0", "debug": "2.6.9", "depd": "~1.1.2", "on-finished": "~2.3.0", "on-headers": "~1.0.1" } }, "sha512-HQStPIV4y3afTiCYVxirakhlCfGkI161c76kKFca7Fk1JusM//Qeo1ej2XaMniiNeaZklMVrh3vTtIzpzwbpmA=="],
|
||||
|
||||
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"nocache": ["nocache@2.0.0", "", {}, "sha512-YdKcy2x0dDwOh+8BEuHvA+mnOKAhmMQDgKBOCUGaLpewdmsRYguYZSom3yA+/OrE61O/q+NMQANnun65xpI1Hw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"node-rsa": ["node-rsa@0.4.2", "", { "dependencies": { "asn1": "0.2.3" } }, "sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA=="],
|
||||
|
||||
"node-statsd": ["node-statsd@0.1.1", "", {}, "sha512-QDf6R8VXF56QVe1boek8an/Rb3rSNaxoFWb7Elpsv2m1+Noua1yy0F1FpKpK5VluF8oymWM4w764A4KsYL4pDg=="],
|
||||
|
||||
"oauth-sign": ["oauth-sign@0.9.0", "", {}, "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
|
||||
|
||||
"object.getownpropertydescriptors": ["object.getownpropertydescriptors@2.1.9", "", { "dependencies": { "array.prototype.reduce": "^1.0.8", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "gopd": "^1.2.0", "safe-array-concat": "^1.1.3" } }, "sha512-mt8YM6XwsTTovI+kdZdHSxoyF2DI59up034orlC9NfweclcWOt7CVascNNLp6U+bjFVCVCIh9PwS76tDM/rH8g=="],
|
||||
|
||||
"on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="],
|
||||
|
||||
"on-headers": ["on-headers@1.0.2", "", {}, "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"partof": ["partof@1.0.0", "", {}, "sha512-+TXdhKCySpJDynCxgAPoGVyAkiK3QPusQ63/BdU5t68QcYzyU6zkP/T7F3gkMQBVUYqdWEADKa6Kx5zg8QIKrg=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@0.1.7", "", {}, "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"processenv": ["processenv@1.1.0", "", { "dependencies": { "babel-runtime": "6.26.0" } }, "sha512-SymqIsn8GjEUy8nG7HiyEjgbfk1xFosRIakUX1NHLpriq3vVpKniGrr9RdMWCaGYWByIovbRt2f/WvmP/IOApQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qs": ["qs@6.5.5", "", {}, "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@2.3.3", "", { "dependencies": { "bytes": "3.0.0", "http-errors": "1.6.3", "iconv-lite": "0.4.23", "unpipe": "1.0.0" } }, "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.12.1", "", {}, "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"request": ["request@2.88.2", "", { "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", "caseless": "~0.12.0", "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~2.3.2", "har-validator": "~5.1.3", "http-signature": "~1.2.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "oauth-sign": "~0.9.0", "performance-now": "^2.1.0", "qs": "~6.5.2", "safe-buffer": "^5.1.2", "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" } }, "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
|
||||
|
||||
"selectn": ["selectn@0.9.6", "", {}, "sha512-XOdvku6f41EAVbZGYTTyPp0CHWmUVYNuq81Hgy+4zEBZqFVk55rD1E3t1mqJZF0qZG40+PCJd+DQ8zUFy4v5Jg=="],
|
||||
|
||||
"semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||
|
||||
"send": ["send@0.16.2", "", { "dependencies": { "debug": "2.6.9", "depd": "~1.1.2", "destroy": "~1.0.4", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "~1.6.2", "mime": "1.4.1", "ms": "2.0.0", "on-finished": "~2.3.0", "range-parser": "~1.2.0", "statuses": "~1.4.0" } }, "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw=="],
|
||||
|
||||
"serve-static": ["serve-static@1.13.2", "", { "dependencies": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "parseurl": "~1.3.2", "send": "0.16.2" } }, "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
|
||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.1.0", "", {}, "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ=="],
|
||||
|
||||
"sha-1": ["sha-1@0.1.1", "", {}, "sha512-dexizf3hB7d4Jq6Cd0d/NYQiqgEqIfZIpuMfwPfvSb6h06DZKmHyUe55jYwpHC12R42wpqXO6ouhiBpRzIcD/g=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"soap": ["soap@0.12.0", "", { "dependencies": { "debug": "~0.7.4", "lodash": "3.x.x", "request": ">=2.9.0", "sax": ">=0.6", "selectn": "^0.9.6", "strip-bom": "~0.3.1" } }, "sha512-rAy5FjvlAB0lnup4+cuJP9dq/Av5jzV9fuJZUW4R5Z5POrzrL1pXTsWUGJV9ua15jum40iscM/rYHXhUCp/vWQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"split2": ["split2@3.0.0", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-Cp7G+nUfKJyHCrAI8kze3Q00PFGEG1pMgrAlTFlDbn+GW24evSZHJuMl+iUJx1w/NTRDeBiTgvwnf6YOt94FMw=="],
|
||||
|
||||
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
|
||||
|
||||
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
|
||||
|
||||
"statuses": ["statuses@1.4.0", "", {}, "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew=="],
|
||||
|
||||
"stethoskop": ["stethoskop@1.0.0", "", { "dependencies": { "node-statsd": "0.1.1" } }, "sha512-4JnZ+UmTs9SFfDjSHFlD/EoXcb1bfwntkt4h1ipNGrpxtRzmHTxOmdquCJvIrVu608Um7a09cGX0ZSOSllWJNQ=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
|
||||
|
||||
"string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="],
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"stringify-object": ["stringify-object@3.3.0", "", { "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", "is-regexp": "^1.0.0" } }, "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw=="],
|
||||
|
||||
"strip-bom": ["strip-bom@0.3.1", "", { "dependencies": { "first-chunk-stream": "^0.1.0", "is-utf8": "^0.2.0" }, "bin": { "strip-bom": "cli.js" } }, "sha512-8m24eJUyKXllSCydAwFVbr4QRZrRb82T2QfwtbO9gTLWhWIOxoDEZESzCGMgperFNyLhly6SDOs+LPH6/seBfw=="],
|
||||
|
||||
"supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
|
||||
|
||||
"tailwind": ["tailwind@4.0.0", "", { "dependencies": { "@babel/runtime": "7.3.4", "ajv": "6.10.0", "app-root-path": "2.1.0", "async-retry": "1.2.3", "body-parser": "1.18.3", "commands-events": "1.0.4", "compression": "1.7.3", "content-type": "1.0.4", "cors": "2.8.5", "crypto2": "2.0.0", "datasette": "1.0.1", "draht": "1.0.1", "express": "4.16.4 ", "flaschenpost": "1.1.3", "hase": "2.0.0", "json-lines": "1.0.0", "limes": "2.0.0", "lodash": "4.17.11", "lusca": "1.6.1", "morgan": "1.9.1", "nocache": "2.0.0", "partof": "1.0.0", "processenv": "1.1.0", "stethoskop": "1.0.0", "timer2": "1.0.0", "uuidv4": "3.0.1", "ws": "6.2.0" } }, "sha512-LlUNoD/5maFG1h5kQ6/hXfFPdcnYw+1Z7z+kUD/W/E71CUMwcnrskxiBM8c3G8wmPsD1VvCuqGYMHviI8+yrmg=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.0", "", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="],
|
||||
|
||||
"timer2": ["timer2@1.0.0", "", {}, "sha512-UOZql+P2ET0da+B7V3/RImN3IhC5ghb+9cpecfUhmYGIm0z73dDr3A781nBLnFYmRzeT1AmoT4w9Lgr8n7n7xg=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g=="],
|
||||
|
||||
"tsscmp": ["tsscmp@1.0.6", "", {}, "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
|
||||
|
||||
"typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"untildify": ["untildify@3.0.3", "", {}, "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"util.promisify": ["util.promisify@1.0.0", "", { "dependencies": { "define-properties": "^1.1.2", "object.getownpropertydescriptors": "^2.0.3" } }, "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA=="],
|
||||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="],
|
||||
|
||||
"uuidv4": ["uuidv4@3.0.1", "", { "dependencies": { "uuid": "3.3.2" } }, "sha512-PPzksdWRl2a5C9hrs3OOYrArTeyoR0ftJ3jtOy+BnVHkT2UlrrzPNt9nTdiGuxmQItHM/AcTXahwZZC57Njojg=="],
|
||||
|
||||
"varname": ["varname@2.0.3", "", {}, "sha512-+DofT9mJAUALhnr9ipZ5Z2icwaEZ7DAajOZT4ffXy3MQqnXtG3b7atItLQEJCkfcJTOf9WcsywneOEibD4eqJg=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
"which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
|
||||
|
||||
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"ws": ["ws@6.2.0", "", { "dependencies": { "async-limiter": "~1.0.0" } }, "sha512-deZYUNlt2O4buFCa3t5bKLf8A7FPP/TVjwOeVNpw818Ma5nk4MLXls2eoEGS39o8119QIYxTrTDoPQ5B/gTD6w=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"amqplib/readable-stream": ["readable-stream@1.1.14", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="],
|
||||
|
||||
"babel-runtime/regenerator-runtime": ["regenerator-runtime@0.11.1", "", {}, "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="],
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"body-parser/qs": ["qs@6.5.2", "", {}, "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="],
|
||||
|
||||
"commands-events/@babel/runtime": ["@babel/runtime@7.2.0", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg=="],
|
||||
|
||||
"commands-events/uuidv4": ["uuidv4@2.0.0", "", { "dependencies": { "sha-1": "0.1.1", "uuid": "3.3.2" } }, "sha512-sAUlwUVepcVk6bwnaW/oi6LCwMdueako5QQzRr90ioAVVcms6p1mV0PaSxK8gyAC4CRvKddsk217uUpZUbKd2Q=="],
|
||||
|
||||
"compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"datasette/lodash": ["lodash@4.17.5", "", {}, "sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw=="],
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"express/qs": ["qs@6.5.2", "", {}, "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="],
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"flaschenpost/@babel/runtime": ["@babel/runtime@7.2.0", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg=="],
|
||||
|
||||
"flaschenpost/lodash": ["lodash@4.17.11", "", {}, "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg=="],
|
||||
|
||||
"har-validator/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"hase/@babel/runtime": ["@babel/runtime@7.1.2", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-Y3SCjmhSupzFB6wcv1KmmFucH6gDVnI30WjOcicV10ju0cZjak3Jcs67YLIXBrmZYw1xCrVeJPbycFwrqNyxpg=="],
|
||||
|
||||
"jsonwebtoken/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"tailwind/lodash": ["lodash@4.17.11", "", {}, "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg=="],
|
||||
|
||||
"uuidv4/uuid": ["uuid@3.3.2", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA=="],
|
||||
|
||||
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"amqplib/readable-stream/string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="],
|
||||
|
||||
"commands-events/uuidv4/uuid": ["uuid@3.3.2", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA=="],
|
||||
|
||||
"har-validator/ajv/fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mode3 - QuickBooks Integration</title>
|
||||
<link rel="stylesheet" href="output.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
padding: 40px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.status-label {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.status-ok {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
button {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
button:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
button.danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
button.danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.form-group {
|
||||
margin: 15px 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.message {
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-top: 15px;
|
||||
display: none;
|
||||
}
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
display: block;
|
||||
}
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔌 Mode3 - QuickBooks Integration</h1>
|
||||
<p class="subtitle">SOAP Server for QuickBooks Web Connector</p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Server Status</h2>
|
||||
<p>Status: <span id="serverStatus" class="status-label status-ok">✓ Running</span></p>
|
||||
<p>Mode: <strong>Mode3</strong></p>
|
||||
<p>Endpoint: <strong>http://localhost:3005/ws</strong></p>
|
||||
<button onclick="checkStatus()">Refresh Status</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>QuickBooks Authentication</h2>
|
||||
<div class="form-group">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" placeholder="Enter username" value="admin">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" placeholder="Enter password" value="password">
|
||||
</div>
|
||||
<button onclick="authenticate()">Authenticate with QB</button>
|
||||
<div id="authMessage" class="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Session Info</h2>
|
||||
<p>Current Session: <span id="sessionStatus" class="status-label status-error">Not authenticated</span></p>
|
||||
<div id="sessionDetails" style="display: none;">
|
||||
<p><strong>Company File:</strong> <span id="companyFile"></span></p>
|
||||
<p><strong>User:</strong> <span id="sessionUser"></span></p>
|
||||
<p><strong>Expires:</strong> <span id="sessionExpires"></span></p>
|
||||
<button class="danger" onclick="logout()">🔌 Close QB Connection / Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Enter Credit Card Charge</h2>
|
||||
<div class="form-group">
|
||||
<label for="ccAccount">Credit Card Account:</label>
|
||||
<input type="text" id="ccAccount" placeholder="e.g., 21010 - Kum&Go Fleet Card" value="21010 - Kum&Go Fleet Card">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vendor">Purchased From (Vendor):</label>
|
||||
<input type="text" id="vendor" placeholder="e.g., Gas Station">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="chargeDate">Date:</label>
|
||||
<input type="text" id="chargeDate" placeholder="MM/DD/YYYY">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="refNum">Ref No:</label>
|
||||
<input type="text" id="refNum" placeholder="e.g., 123456789">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="amount">Amount ($):</label>
|
||||
<input type="text" id="amount" placeholder="e.g., 12.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="expenseAccount">Expense Account:</label>
|
||||
<input type="text" id="expenseAccount" placeholder="e.g., 60000 - Semi-Variable Expenses:50160 - Gas/Fuel">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="memo">Memo:</label>
|
||||
<input type="text" id="memo" placeholder="Optional memo">
|
||||
</div>
|
||||
<button onclick="addCharge()">Add Charge</button>
|
||||
<div id="chargeMessage" class="message"></div>
|
||||
<button onclick="viewCharges()" style="background: #764ba2;">View Queued Charges</button>
|
||||
<div id="chargesList" style="display: none; margin-top: 20px;">
|
||||
<h3>Pending Charges</h3>
|
||||
<div id="chargesContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Documentation</h2>
|
||||
<ul>
|
||||
<li><strong>SOAP Endpoint:</strong> <code>POST /ws</code> - QuickBooks Web Connector SOAP interface</li>
|
||||
<li><strong>WSDL:</strong> <code>GET /ws</code> - WSDL definition for QB Web Connector</li>
|
||||
<li><strong>Status:</strong> <code>GET /quickbooks/status</code> - Check authentication status</li>
|
||||
<li><strong>Auth:</strong> <code>POST /quickbooks/auth</code> - REST authentication endpoint</li>
|
||||
<li><strong>Health:</strong> <code>GET /health</code> - Server health check</li>
|
||||
<li><strong>Charges:</strong> <code>POST /api/charges</code> - Submit credit card charge</li>
|
||||
<li><strong>Charges List:</strong> <code>GET /api/charges</code> - List all charges</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set today's date as default
|
||||
function setDefaultDate() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('chargeDate').value = today;
|
||||
}
|
||||
|
||||
async function addCharge() {
|
||||
const msgDiv = document.getElementById('chargeMessage');
|
||||
try {
|
||||
const charge = {
|
||||
creditCardAccount: document.getElementById('ccAccount').value || '21010 - Kum&Go Fleet Card',
|
||||
vendor: document.getElementById('vendor').value || 'Unknown Vendor',
|
||||
amount: document.getElementById('amount').value,
|
||||
expenseAccount: document.getElementById('expenseAccount').value || '60000 - Semi-Variable Expenses',
|
||||
date: document.getElementById('chargeDate').value,
|
||||
refNum: document.getElementById('refNum').value || undefined,
|
||||
memo: document.getElementById('memo').value || undefined
|
||||
};
|
||||
|
||||
if (!charge.vendor || !charge.amount) {
|
||||
msgDiv.className = 'message error';
|
||||
msgDiv.textContent = '✗ Vendor and Amount are required';
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/charges', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(charge)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
msgDiv.className = 'message success';
|
||||
msgDiv.textContent = '✓ ' + data.message + ' (ID: ' + data.charge.id + ')';
|
||||
// Clear form
|
||||
document.getElementById('vendor').value = '';
|
||||
document.getElementById('amount').value = '';
|
||||
document.getElementById('refNum').value = '';
|
||||
document.getElementById('memo').value = '';
|
||||
setDefaultDate();
|
||||
viewCharges();
|
||||
} else {
|
||||
msgDiv.className = 'message error';
|
||||
msgDiv.textContent = '✗ ' + data.error;
|
||||
}
|
||||
} catch (error) {
|
||||
msgDiv.className = 'message error';
|
||||
msgDiv.textContent = '✗ Failed to add charge: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function viewCharges() {
|
||||
const container = document.getElementById('chargesContainer');
|
||||
try {
|
||||
const res = await fetch('/api/charges');
|
||||
const data = await res.json();
|
||||
|
||||
let html = `<p><strong>Total Charges: ${data.total}</strong></p>`;
|
||||
|
||||
if (data.pending.length > 0) {
|
||||
html += '<h4>Pending (Not Yet Sent)</h4><ul>';
|
||||
data.pending.forEach(ch => {
|
||||
html += `<li>${ch.vendor} - $${ch.amount} on ${ch.date} (${ch.id})</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
if (data.sent.length > 0) {
|
||||
html += '<h4>Sent to QB (Awaiting Processing)</h4><ul>';
|
||||
data.sent.forEach(ch => {
|
||||
html += `<li>${ch.vendor} - $${ch.amount} on ${ch.date} (${ch.id})</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
if (data.total === 0) {
|
||||
html += '<p><em>No charges queued</em></p>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
document.getElementById('chargesList').style.display = 'block';
|
||||
} catch (error) {
|
||||
container.innerHTML = '<p class="error">Failed to load charges</p>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const res = await fetch('/health');
|
||||
const data = await res.json();
|
||||
document.getElementById('serverStatus').className = 'status-label status-ok';
|
||||
document.getElementById('serverStatus').textContent = '✓ Running';
|
||||
updateSessionInfo();
|
||||
} catch (error) {
|
||||
document.getElementById('serverStatus').className = 'status-label status-error';
|
||||
document.getElementById('serverStatus').textContent = '✗ Offline';
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of existing code remains the same ...
|
||||
|
||||
// Check status on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setDefaultDate();
|
||||
checkStatus();
|
||||
updateSessionInfo();
|
||||
});
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const messageDiv = document.getElementById('authMessage');
|
||||
|
||||
try {
|
||||
const res = await fetch('/quickbooks/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
messageDiv.className = 'message success';
|
||||
messageDiv.textContent = '✓ ' + data.message;
|
||||
setTimeout(() => updateSessionInfo(), 500);
|
||||
} else {
|
||||
messageDiv.className = 'message error';
|
||||
messageDiv.textContent = '✗ ' + data.error;
|
||||
}
|
||||
} catch (error) {
|
||||
messageDiv.className = 'message error';
|
||||
messageDiv.textContent = '✗ Connection failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSessionInfo() {
|
||||
try {
|
||||
const res = await fetch('/quickbooks/status');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
document.getElementById('sessionStatus').className = 'status-label status-ok';
|
||||
document.getElementById('sessionStatus').textContent = '✓ Authenticated';
|
||||
document.getElementById('companyFile').textContent = data.companyFile;
|
||||
document.getElementById('sessionUser').textContent = data.userName;
|
||||
document.getElementById('sessionExpires').textContent = data.sessionExpires;
|
||||
document.getElementById('sessionDetails').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('sessionStatus').className = 'status-label status-error';
|
||||
document.getElementById('sessionStatus').textContent = '✗ Not authenticated';
|
||||
document.getElementById('sessionDetails').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update session info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
const res = await fetch('/quickbooks/logout', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
updateSessionInfo();
|
||||
document.getElementById('authMessage').className = 'message success';
|
||||
const timestamp = data.closedAt ? ` (${new Date(data.closedAt).toLocaleTimeString()})` : '';
|
||||
document.getElementById('authMessage').textContent = '✓ ' + data.message + timestamp;
|
||||
} else {
|
||||
document.getElementById('authMessage').className = 'message error';
|
||||
document.getElementById('authMessage').textContent = '✗ ' + (data.error || 'Logout failed');
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('authMessage').className = 'message error';
|
||||
document.getElementById('authMessage').textContent = '✗ Logout failed: ' + error.message;
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check status on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkStatus();
|
||||
updateSessionInfo();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "job-info-mode3",
|
||||
"version": "1.0.0-mode3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --watch backend/server.ts",
|
||||
"start": "bun run backend/server.ts",
|
||||
"build:css": "tailwindcss -i input.css -o frontend/output.css --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"soap": "^0.12.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"tailwind": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./frontend/**/*.{html,js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
# Mode5Test - Working Version
|
||||
|
||||
**Status:** ✅ WORKING AND STABLE
|
||||
|
||||
**Created:** January 19, 2026
|
||||
|
||||
## Details
|
||||
|
||||
This is an exact copy of Mode3Test, which has been confirmed as the last working version of the application. This copy was created to establish a stable baseline for the orchestrator.
|
||||
|
||||
## What's Working
|
||||
|
||||
- Backend service (Hono/Bun) running on port 3005
|
||||
- Frontend serving static files with PocketBase authentication
|
||||
- Job file integration with Microsoft Graph API
|
||||
- Token management and caching
|
||||
- SharePoint document access and resolution
|
||||
|
||||
## Authentication
|
||||
|
||||
- Uses PocketBase at `https://pocketbase.ccllc.pro`
|
||||
- OAuth flow via Microsoft Graph
|
||||
- Token-based auth with fallback to signin page
|
||||
|
||||
## Environment
|
||||
|
||||
- Uses `.env` configuration from Mode3Test
|
||||
- Redis connection attempted but not required for core functionality
|
||||
- All dependencies locked in `bun.lock`
|
||||
|
||||
## Instructions for Future Development
|
||||
|
||||
To make changes:
|
||||
1. Update files directly in Mode5Test
|
||||
2. Test thoroughly before committing
|
||||
3. If breaking changes occur, restore from git history
|
||||
4. Document any significant changes in this file
|
||||
|
||||
## Lock Status
|
||||
|
||||
This directory is locked as read-only to prevent accidental modifications. Use `chmod -R u+w Mode5Test` to unlock if needed.
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* ROUTES: OAuth Authentication
|
||||
*
|
||||
* PURPOSE: Handle OAuth2 authorization and token exchange
|
||||
* ENDPOINTS:
|
||||
* - POST /api/auth/authorize - Exchange authorization code for token
|
||||
* - POST /api/auth/refresh - Refresh access token using refresh token
|
||||
* - POST /api/auth/logout - Logout and invalidate tokens
|
||||
*
|
||||
* SECURITY:
|
||||
* - Client secret kept server-side only
|
||||
* - Refresh tokens stored in HttpOnly cookies
|
||||
* - PKCE verification with code verifier
|
||||
* - CORS protection
|
||||
*/
|
||||
|
||||
import { Context } from 'hono';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface AuthorizeRequest {
|
||||
code: string;
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
user: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
mail: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RefreshTokenRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const MICROSOFT_TENANT_ID = process.env.MICROSOFT_TENANT_ID || 'common';
|
||||
const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || '';
|
||||
const MICROSOFT_CLIENT_SECRET = process.env.MICROSOFT_CLIENT_SECRET || '';
|
||||
const REDIRECT_URI = process.env.MICROSOFT_REDIRECT_URI || '';
|
||||
|
||||
// Token endpoints
|
||||
const TOKEN_ENDPOINT = `https://login.microsoftonline.com/${MICROSOFT_TENANT_ID}/oauth2/v2.0/token`;
|
||||
const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0/me';
|
||||
|
||||
// In-memory token store (in production, use Redis or database)
|
||||
const tokenStore = new Map<string, { refreshToken: string; expiresAt: number }>();
|
||||
|
||||
// ============================================================================
|
||||
// AUTHORIZATION ENDPOINT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/auth/authorize
|
||||
*
|
||||
* Exchange authorization code for access token
|
||||
*
|
||||
* REQUEST:
|
||||
* {
|
||||
* code: string (from Microsoft OAuth redirect)
|
||||
* state: string (CSRF token)
|
||||
* codeVerifier: string (PKCE code verifier)
|
||||
* }
|
||||
*
|
||||
* RESPONSE:
|
||||
* {
|
||||
* accessToken: string
|
||||
* refreshToken: string
|
||||
* expiresIn: number (seconds)
|
||||
* user: { id, displayName, mail }
|
||||
* }
|
||||
*/
|
||||
export async function handleAuthorize(c: Context): Promise<Response> {
|
||||
try {
|
||||
const body = await c.req.json() as AuthorizeRequest;
|
||||
const { code, state, codeVerifier } = body;
|
||||
|
||||
// Validate inputs
|
||||
if (!code || !state || !codeVerifier) {
|
||||
return c.json(
|
||||
{ error: 'Missing required parameters', details: 'code, state, codeVerifier required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if (!MICROSOFT_CLIENT_ID || !MICROSOFT_CLIENT_SECRET || !REDIRECT_URI) {
|
||||
console.error('[Auth] Missing OAuth configuration');
|
||||
return c.json(
|
||||
{ error: 'OAuth configuration incomplete' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Auth] Exchanging authorization code for token');
|
||||
|
||||
// Step 1: Exchange code for token with Microsoft
|
||||
const tokenParams = new URLSearchParams({
|
||||
client_id: MICROSOFT_CLIENT_ID,
|
||||
client_secret: MICROSOFT_CLIENT_SECRET,
|
||||
code: code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
grant_type: 'authorization_code',
|
||||
scope: 'offline_access',
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
console.error('[Auth] Token exchange failed:', error);
|
||||
return c.json(
|
||||
{ error: 'Failed to exchange code for token', details: error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json() as any;
|
||||
const accessToken = tokenData.access_token;
|
||||
const refreshToken = tokenData.refresh_token;
|
||||
const expiresIn = tokenData.expires_in || 3600;
|
||||
|
||||
if (!accessToken) {
|
||||
console.error('[Auth] No access token in response');
|
||||
return c.json(
|
||||
{ error: 'No access token in response' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Auth] Successfully exchanged code for token');
|
||||
|
||||
// Step 2: Get user profile from Microsoft Graph
|
||||
const userResponse = await fetch(GRAPH_ENDPOINT, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
console.error('[Auth] Failed to fetch user profile');
|
||||
return c.json(
|
||||
{ error: 'Failed to fetch user profile' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await userResponse.json() as any;
|
||||
|
||||
console.log('[Auth] Retrieved user profile:', user.displayName);
|
||||
|
||||
// Step 3: Store refresh token server-side
|
||||
if (refreshToken) {
|
||||
const expiresAt = Date.now() + (tokenData.refresh_token_expires_in || 90 * 24 * 60 * 60) * 1000;
|
||||
tokenStore.set(user.id, { refreshToken, expiresAt });
|
||||
console.log('[Auth] Stored refresh token for user:', user.id);
|
||||
}
|
||||
|
||||
// Step 4: Return response
|
||||
const response: TokenResponse = {
|
||||
accessToken,
|
||||
refreshToken: refreshToken || '',
|
||||
expiresIn,
|
||||
user: {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
mail: user.mail,
|
||||
},
|
||||
};
|
||||
|
||||
return c.json(response);
|
||||
} catch (error) {
|
||||
console.error('[Auth] Authorization handler error:', error);
|
||||
return c.json(
|
||||
{ error: 'Internal server error', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOKEN REFRESH ENDPOINT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
*
|
||||
* Refresh access token using refresh token
|
||||
*
|
||||
* REQUEST:
|
||||
* {
|
||||
* refreshToken: string
|
||||
* }
|
||||
*
|
||||
* RESPONSE:
|
||||
* {
|
||||
* accessToken: string
|
||||
* expiresIn: number (seconds)
|
||||
* }
|
||||
*/
|
||||
export async function handleRefresh(c: Context): Promise<Response> {
|
||||
try {
|
||||
const body = await c.req.json() as RefreshTokenRequest;
|
||||
const { refreshToken } = body;
|
||||
|
||||
if (!refreshToken) {
|
||||
return c.json(
|
||||
{ error: 'Refresh token required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Auth] Refreshing access token');
|
||||
|
||||
// Exchange refresh token for new access token
|
||||
const tokenParams = new URLSearchParams({
|
||||
client_id: MICROSOFT_CLIENT_ID,
|
||||
client_secret: MICROSOFT_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
scope: 'offline_access',
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
console.error('[Auth] Token refresh failed:', error);
|
||||
return c.json(
|
||||
{ error: 'Failed to refresh token' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json() as any;
|
||||
const newAccessToken = tokenData.access_token;
|
||||
const expiresIn = tokenData.expires_in || 3600;
|
||||
|
||||
console.log('[Auth] Successfully refreshed access token');
|
||||
|
||||
return c.json({
|
||||
accessToken: newAccessToken,
|
||||
expiresIn,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] Refresh handler error:', error);
|
||||
return c.json(
|
||||
{ error: 'Internal server error', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOGOUT ENDPOINT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
*
|
||||
* Logout and invalidate tokens
|
||||
*/
|
||||
export async function handleLogout(c: Context): Promise<Response> {
|
||||
try {
|
||||
console.log('[Auth] Logout requested');
|
||||
|
||||
// In production, would invalidate refresh token in database
|
||||
// For now, just return success
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Auth] Logout handler error:', error);
|
||||
return c.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
// @ts-ignore Bun project may not have dotenv types configured
|
||||
import { config } from 'dotenv';
|
||||
import { getCache, setCache, isRedisConnected } from './redis-cache';
|
||||
@@ -8,6 +9,13 @@ import { getCache, setCache, isRedisConnected } from './redis-cache';
|
||||
config();
|
||||
|
||||
const app = new Hono();
|
||||
const JOBS_CACHE_FILE = path.join(import.meta.dir, '../jobs-cache.json');
|
||||
|
||||
// In-memory cache for fast access
|
||||
let cachedJobs: any = null;
|
||||
let lastCacheTime = 0;
|
||||
let isFetching = false;
|
||||
let partialJobs: any[] = [];
|
||||
|
||||
// Minimal log helpers
|
||||
const logLine = (path: string, line: string) => {
|
||||
@@ -18,6 +26,50 @@ const logLine = (path: string, line: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Cache helper functions
|
||||
const saveCacheFile = async (data: any): Promise<void> => {
|
||||
try {
|
||||
await Bun.write(JOBS_CACHE_FILE, JSON.stringify(data, null, 2));
|
||||
cachedJobs = data;
|
||||
lastCacheTime = Date.now();
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Failed to save cache file: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Fast load: returns first 30 items immediately, full cache lazily
|
||||
const loadCacheFileFast = async (): Promise<any | null> => {
|
||||
try {
|
||||
const file = Bun.file(JOBS_CACHE_FILE);
|
||||
if (!(await file.exists())) return null;
|
||||
|
||||
// Read as text and parse (we're already async, so it's fine)
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
cachedJobs = data;
|
||||
lastCacheTime = Date.now();
|
||||
return data;
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Failed to load cache file: ${err}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCacheFile = async (): Promise<any | null> => {
|
||||
try {
|
||||
const file = Bun.file(JOBS_CACHE_FILE);
|
||||
if (await file.exists()) {
|
||||
const data = JSON.parse(await file.text());
|
||||
cachedJobs = data;
|
||||
lastCacheTime = Date.now();
|
||||
return data;
|
||||
}
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Failed to load cache file: ${err}`);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Version endpoint sourced from package.json
|
||||
app.get('/version', (c) => {
|
||||
try {
|
||||
@@ -78,74 +130,194 @@ app.get('/api/jobs', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Fast endpoint: returns all jobs in a single cached request
|
||||
// Fast endpoint: returns jobs with pagination
|
||||
app.get('/api/jobs-all', async (c) => {
|
||||
const cacheKey = 'jobs:all';
|
||||
const ttl = 1800; // 30 minutes cache
|
||||
try {
|
||||
// Try cache first for instant response
|
||||
if (isRedisConnected()) {
|
||||
const cached = await getCache(cacheKey);
|
||||
if (cached) {
|
||||
logLine('logs/server.log', `Cache HIT for ${cacheKey} (${cached.items?.length || 0} jobs)`);
|
||||
// Return cached data immediately
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await refreshJobsCache(cacheKey, ttl, c.req.header('Authorization') || '');
|
||||
} catch (err) {}
|
||||
// Get page and perPage from query params, default to first page with 100 items
|
||||
const page = parseInt(c.req.query('page') || '1');
|
||||
const perPage = parseInt(c.req.query('perPage') || '100');
|
||||
|
||||
// Return in-memory cache if available
|
||||
if (cachedJobs && cachedJobs.items && Array.isArray(cachedJobs.items)) {
|
||||
const allJobs = cachedJobs.items;
|
||||
const totalItems = allJobs.length;
|
||||
const totalPages = Math.ceil(totalItems / perPage);
|
||||
const startIdx = (page - 1) * perPage;
|
||||
const endIdx = startIdx + perPage;
|
||||
const pageJobs = allJobs.slice(startIdx, endIdx);
|
||||
|
||||
// Refresh in background if older than 5 minutes
|
||||
if (Date.now() - lastCacheTime > 5 * 60 * 1000) {
|
||||
fetchAllJobsProgressively(c.req.header('Authorization') || '').catch(err => {
|
||||
logLine('logs/error.log', `Background refresh error: ${err}`);
|
||||
});
|
||||
// For ultra-fast display, send only the first 40 jobs in the initial response
|
||||
const fastBatch = Array.isArray(cached.items) ? cached.items.slice(0, 40) : [];
|
||||
return c.json({ ...cached, items: fastBatch, partial: cached.items && cached.items.length > 40 });
|
||||
} else {
|
||||
// No cache: return empty array instantly, trigger background fetch
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
|
||||
if (isRedisConnected()) {
|
||||
await setCache(cacheKey, result, ttl);
|
||||
logLine('logs/server.log', `Cached ${cacheKey} with ${result.items.length} jobs for ${ttl}s`);
|
||||
}
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `jobs-all background fetch error: ${(err as Error)?.message || String(err)}`);
|
||||
}
|
||||
});
|
||||
return c.json({ items: [], partial: true });
|
||||
}
|
||||
|
||||
return c.json({
|
||||
page,
|
||||
perPage: pageJobs.length,
|
||||
totalItems,
|
||||
totalPages,
|
||||
items: pageJobs
|
||||
});
|
||||
}
|
||||
// Redis not connected: fallback to direct fetch (blocking)
|
||||
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `jobs-all endpoint error: ${(err as Error)?.message || String(err)}`);
|
||||
|
||||
// Try to load from disk if not in memory
|
||||
const diskCache = await loadCacheFile();
|
||||
if (diskCache && diskCache.items && Array.isArray(diskCache.items)) {
|
||||
const allJobs = diskCache.items;
|
||||
const totalItems = allJobs.length;
|
||||
const totalPages = Math.ceil(totalItems / perPage);
|
||||
const startIdx = (page - 1) * perPage;
|
||||
const endIdx = startIdx + perPage;
|
||||
const pageJobs = allJobs.slice(startIdx, endIdx);
|
||||
|
||||
// Refresh in background
|
||||
fetchAllJobsProgressively(c.req.header('Authorization') || '').catch(err => {
|
||||
logLine('logs/error.log', `Background refresh error: ${err}`);
|
||||
});
|
||||
|
||||
return c.json({
|
||||
page,
|
||||
perPage: pageJobs.length,
|
||||
totalItems,
|
||||
totalPages,
|
||||
items: pageJobs
|
||||
});
|
||||
}
|
||||
|
||||
// No cache: fetch first page from PocketBase
|
||||
if (!isFetching) {
|
||||
isFetching = true;
|
||||
fetchAllJobsProgressively(c.req.header('Authorization') || '').catch(err => {
|
||||
logLine('logs/error.log', `Progressive fetch error: ${err}`);
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Return first batch immediately
|
||||
try {
|
||||
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=1&perPage=100&sort=-Job_Number`;
|
||||
const response = await fetch(pbUrl, {
|
||||
headers: c.req.header('Authorization') ? { Authorization: c.req.header('Authorization')! } : {}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
return c.json({
|
||||
page: 1,
|
||||
perPage: items.length,
|
||||
totalItems: items.length,
|
||||
totalPages: 1,
|
||||
items: items
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Failed to get first page: ${err}`);
|
||||
}
|
||||
|
||||
return c.json({ error: 'No cache available and initial fetch failed' }, 503);
|
||||
} catch(err) {
|
||||
logLine('logs/error.log', `jobs-all endpoint error: ${err}`);
|
||||
return c.json({ error: 'Failed to fetch jobs' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to fetch all jobs from PocketBase
|
||||
async function fetchAllJobsFromPocketBase(authHeader: string): Promise<any> {
|
||||
const allItems: any[] = [];
|
||||
let page = 1;
|
||||
const perPage = 500;
|
||||
|
||||
while (true) {
|
||||
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`;
|
||||
const response = await fetch(pbUrl, {
|
||||
// Progressive fetch helper: fetches all jobs in background, updating cache as it goes
|
||||
async function fetchAllJobsProgressively(authHeader: string): Promise<void> {
|
||||
try {
|
||||
const perPage = 500;
|
||||
|
||||
// Get first page to determine total
|
||||
const firstUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=1&perPage=${perPage}&sort=-Job_Number`;
|
||||
const firstResponse = await fetch(firstUrl, {
|
||||
headers: authHeader ? { Authorization: authHeader } : {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PocketBase returned ${response.status}`);
|
||||
if (!firstResponse.ok) {
|
||||
throw new Error(`PocketBase returned ${firstResponse.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
const firstData = await firstResponse.json();
|
||||
const totalItems = firstData.totalItems || 0;
|
||||
const totalPages = Math.ceil(totalItems / perPage);
|
||||
|
||||
if (items.length === 0) break;
|
||||
allItems.push(...items);
|
||||
const allItems = [...(firstData.items || [])];
|
||||
partialJobs = allItems;
|
||||
|
||||
if (allItems.length >= (data.totalItems || 0)) break;
|
||||
page++;
|
||||
// Fetch remaining pages in parallel
|
||||
if (totalPages > 1) {
|
||||
const pagePromises = [];
|
||||
for (let page = 2; page <= totalPages; page++) {
|
||||
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`;
|
||||
pagePromises.push(
|
||||
fetch(pbUrl, {
|
||||
headers: authHeader ? { Authorization: authHeader } : {}
|
||||
}).then(r => r.json())
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(pagePromises);
|
||||
for (const result of results) {
|
||||
allItems.push(...(result.items || []));
|
||||
}
|
||||
}
|
||||
|
||||
// Save final result to cache file
|
||||
const cacheData = {
|
||||
page: 1,
|
||||
perPage: allItems.length,
|
||||
totalItems: allItems.length,
|
||||
totalPages: 1,
|
||||
items: allItems
|
||||
};
|
||||
|
||||
await saveCacheFile(cacheData);
|
||||
isFetching = false;
|
||||
logLine('logs/server.log', `✓ Progressive fetch complete: ${allItems.length} jobs cached`);
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Progressive fetch failed: ${err}`);
|
||||
isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to fetch all jobs from PocketBase
|
||||
async function fetchAllJobsFromPocketBase(authHeader: string): Promise<any> {
|
||||
const perPage = 500;
|
||||
|
||||
// First, get total count
|
||||
const firstUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=1&perPage=${perPage}&sort=-Job_Number`;
|
||||
const firstResponse = await fetch(firstUrl, {
|
||||
headers: authHeader ? { Authorization: authHeader } : {}
|
||||
});
|
||||
|
||||
if (!firstResponse.ok) {
|
||||
throw new Error(`PocketBase returned ${firstResponse.status}`);
|
||||
}
|
||||
|
||||
const firstData = await firstResponse.json();
|
||||
const totalItems = firstData.totalItems || 0;
|
||||
const totalPages = Math.ceil(totalItems / perPage);
|
||||
|
||||
// Fetch all pages in parallel
|
||||
const allItems = [...(firstData.items || [])];
|
||||
|
||||
if (totalPages > 1) {
|
||||
const pagePromises = [];
|
||||
for (let page = 2; page <= totalPages; page++) {
|
||||
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`;
|
||||
pagePromises.push(
|
||||
fetch(pbUrl, {
|
||||
headers: authHeader ? { Authorization: authHeader } : {}
|
||||
}).then(r => r.json())
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(pagePromises);
|
||||
for (const result of results) {
|
||||
allItems.push(...(result.items || []));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -473,28 +645,37 @@ app.onError((err, c) => {
|
||||
// Default to 3005 for test instance; can be overridden by Environment=PORT
|
||||
const PORT = Number(process.env.PORT || 3005);
|
||||
|
||||
logLine('logs/server.log', `Starting frontend server (Redis version) on port ${PORT}`);
|
||||
logLine('logs/server.log', `Starting frontend server on port ${PORT}`);
|
||||
|
||||
// Warm up cache on startup
|
||||
// Load cache on startup
|
||||
(async () => {
|
||||
try {
|
||||
if (isRedisConnected()) {
|
||||
logLine('logs/server.log', 'Warming up jobs cache...');
|
||||
const result = await fetchAllJobsFromPocketBase('');
|
||||
await setCache('jobs:all', result, 1800);
|
||||
logLine('logs/server.log', `✓ Cache warmed with ${result.items.length} jobs`);
|
||||
const cached = await loadCacheFile();
|
||||
if (cached) {
|
||||
logLine('logs/server.log', `✓ Loaded cache from disk: ${cached.items?.length || 0} jobs`);
|
||||
} else {
|
||||
logLine('logs/server.log', 'No cache file found, will fetch from PocketBase on first request');
|
||||
}
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Cache warmup failed: ${(err as Error)?.message || String(err)}`);
|
||||
logLine('logs/error.log', `Startup cache load error: ${(err as Error)?.message || String(err)}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// Background refresh every 5 minutes to keep cache hot
|
||||
// Immediately load cache synchronously if it exists (for fastest first request)
|
||||
(async () => {
|
||||
try {
|
||||
await loadCacheFile();
|
||||
} catch (err) {
|
||||
// Silent fail
|
||||
}
|
||||
})();
|
||||
|
||||
// Background refresh every 5 minutes to keep cache fresh
|
||||
setInterval(async () => {
|
||||
try {
|
||||
if (isRedisConnected()) {
|
||||
await refreshJobsCache('jobs:all', 1800, '');
|
||||
}
|
||||
const result = await fetchAllJobsFromPocketBase('');
|
||||
await saveCacheFile(result);
|
||||
logLine('logs/server.log', `✓ Background refresh: updated cache with ${result.items?.length || 0} jobs`);
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Periodic refresh error: ${(err as Error)?.message || String(err)}`);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"ioredis": "^5.8.2",
|
||||
"ioredis": "^5.9.2",
|
||||
"pocketbase": "^0.26.5",
|
||||
"tailwind": "^4.0.0",
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"packages": {
|
||||
"@babel/runtime": ["@babel/runtime@7.3.4", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
|
||||
"@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
|
||||
"ioredis": ["ioredis@5.9.2", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
@@ -39,7 +39,7 @@
|
||||
</script>
|
||||
</head>
|
||||
<body class="font-sans bg-gray-100 m-0 text-gray-900 overflow-hidden touch-pan-y h-screen flex flex-col">
|
||||
<div class="max-w-full md:max-w-2xl mx-auto p-4 sm:p-3 text-[17px] flex flex-col flex-1 overflow-hidden">
|
||||
<div class="max-w-full md:max-w-2xl mx-auto p-4 sm:p-3 text-[17px] flex flex-col flex-1 overflow-hidden pb-[84px] md:pb-0">
|
||||
<div class="w-full bg-gray-400 border border-gray-300 rounded-lg px-0.5 py-0.5 mb-3 shadow-sm flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-6 w-[186px] rounded-lg bg-black flex items-center justify-center overflow-hidden">
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
<h1 class="m-0 mb-3 text-center text-blue-600 text-[33px] font-bold">Job Info</h1>
|
||||
|
||||
<div class="w-full bg-blue-50 h-1.5 rounded overflow-hidden mb-3">
|
||||
<div class="hidden w-full bg-blue-50 h-1.5 rounded overflow-hidden mb-3">
|
||||
<div id="progressBar" class="h-full w-0 bg-blue-600 transition-all duration-300 ease-out"></div>
|
||||
</div>
|
||||
|
||||
@@ -112,11 +112,12 @@
|
||||
</div>
|
||||
|
||||
<div id="results" aria-live="polite" class="flex flex-col gap-3 flex-1 overflow-auto p-0"></div>
|
||||
<div class="text-xs text-gray-500 mt-5 pt-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<span id="userEmail" class="truncate"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="signOutBtn" class="px-2 py-1 text-[11px] bg-gray-200 hover:bg-gray-300 rounded border border-gray-300 text-gray-700">Sign out</button>
|
||||
<span id="versionLabel">v1.0.0-beta2</span>
|
||||
<div class="text-[10px] sm:text-xs text-gray-500 mt-5 pt-3 border-t border-gray-200 flex items-center justify-between flex-shrink-0 bg-gray-100">
|
||||
<span id="userEmail" class="truncate text-[9px] sm:text-[10px]"></span>
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<button id="signOutBtn" class="px-1.5 sm:px-2 py-0.5 sm:py-1 text-[9px] sm:text-[11px] bg-gray-200 hover:bg-gray-300 rounded border border-gray-300 text-gray-700 whitespace-nowrap">Sign out</button>
|
||||
<span id="modeLabel" class="px-1.5 sm:px-2 py-0.5 sm:py-1 text-[9px] sm:text-[11px] bg-blue-100 text-blue-700 rounded border border-blue-300 whitespace-nowrap"></span>
|
||||
<span id="versionLabel" class="text-[8px] sm:text-[10px] whitespace-nowrap">v1.0.0-beta2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,21 +244,10 @@
|
||||
const authed = await ensureAuth();
|
||||
if (!authed) return;
|
||||
|
||||
// Make sure a Graph token is available before proceeding; this may refresh silently
|
||||
try {
|
||||
await ensureGraphToken();
|
||||
} catch (err) {
|
||||
console.warn('Graph token missing after refresh; continuing. Preview may prompt for auth.', err?.message || err);
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
const fetchNoCache = (url, options={}) => {
|
||||
const headers = { ...(options.headers||{}), ...authHeaders() };
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return fetch(url + `${separator}_ts=${Date.now()}`, { ...options, headers });
|
||||
};
|
||||
// --- Constants and helpers ---
|
||||
const GRAPH_TOKEN_KEY = 'graphAccessToken';
|
||||
const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
|
||||
|
||||
const extractGraphToken = (metaObj={}) => (
|
||||
metaObj.graphAccessToken ||
|
||||
metaObj.graph_token ||
|
||||
@@ -269,11 +259,12 @@
|
||||
metaObj?.authData?.access_token ||
|
||||
''
|
||||
);
|
||||
|
||||
const getGraphToken = () => {
|
||||
// Prefer runtime token already present in PB model/meta, then localStorage
|
||||
const model = pb?.authStore?.model || {};
|
||||
const meta = model?.meta || {};
|
||||
const token = (
|
||||
return (
|
||||
model.graphAccessToken ||
|
||||
model.graph_token ||
|
||||
model.graphToken ||
|
||||
@@ -283,8 +274,55 @@
|
||||
localStorage.getItem(GRAPH_TOKEN_KEY) ||
|
||||
''
|
||||
);
|
||||
console.log('[getGraphToken] Found token:', token ? token.substring(0, 30) + '...' : 'NONE');
|
||||
return token;
|
||||
};
|
||||
|
||||
const refreshGraphToken = async () => {
|
||||
try {
|
||||
const authData = await pb.collection('users').authRefresh();
|
||||
const meta = authData?.meta || pb?.authStore?.model?.meta || {};
|
||||
const token = extractGraphToken(meta);
|
||||
if (token) localStorage.setItem(GRAPH_TOKEN_KEY, token);
|
||||
return token || '';
|
||||
} catch(err){
|
||||
console.warn('Graph token refresh failed:', err?.message||err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const ensureGraphToken = async () => {
|
||||
let token = getGraphToken();
|
||||
if(token) return token;
|
||||
// If PB session is valid, try to refresh; if still missing, redirect to reauth once
|
||||
if (pb?.authStore?.isValid) {
|
||||
token = await refreshGraphToken();
|
||||
if (token) return token;
|
||||
const attempted = localStorage.getItem(GRAPH_REAUTH_FLAG) === '1';
|
||||
if (!attempted) {
|
||||
localStorage.setItem(GRAPH_REAUTH_FLAG, '1');
|
||||
console.warn('Graph token unavailable after refresh; redirecting once to sign-in to obtain a new token.');
|
||||
window.location.href = 'signin.html?reauth=1';
|
||||
throw new Error('GRAPH_TOKEN missing');
|
||||
}
|
||||
console.warn('Graph token unavailable after refresh; reauth already attempted. Proceeding without token.');
|
||||
return '';
|
||||
}
|
||||
// PB session invalid: redirect to signin
|
||||
window.location.href = 'signin.html';
|
||||
throw new Error('GRAPH_TOKEN missing');
|
||||
}
|
||||
|
||||
// Make sure a Graph token is available before proceeding
|
||||
try {
|
||||
await ensureGraphToken();
|
||||
} catch (err) {
|
||||
console.warn('Graph token missing after refresh; continuing. Preview may prompt for auth.', err?.message || err);
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
const fetchNoCache = (url, options={}) => {
|
||||
const headers = { ...(options.headers||{}), ...authHeaders() };
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return fetch(url + `${separator}_ts=${Date.now()}`, { ...options, headers });
|
||||
};
|
||||
const PB_COLLECTION_URL = "https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records";
|
||||
const NOTES_COLLECTION_URL = "https://pocketbase.ccllc.pro/api/collections/notes/records";
|
||||
@@ -334,42 +372,6 @@
|
||||
if(userEmailEl && currentUserEmail) userEmailEl.textContent = currentUserEmail;
|
||||
|
||||
|
||||
// Ensure Graph token is present; refresh if missing; redirect to signin if still absent
|
||||
async function refreshGraphToken(){
|
||||
try {
|
||||
const authData = await pb.collection('users').authRefresh();
|
||||
const meta = authData?.meta || pb?.authStore?.model?.meta || {};
|
||||
const token = extractGraphToken(meta);
|
||||
if (token) localStorage.setItem(GRAPH_TOKEN_KEY, token);
|
||||
return token || '';
|
||||
} catch(err){
|
||||
console.warn('Graph token refresh failed:', err?.message||err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGraphToken(){
|
||||
let token = getGraphToken();
|
||||
if(token) return token;
|
||||
// If PB session is valid, try to refresh; if still missing, redirect to reauth once
|
||||
if (pb?.authStore?.isValid) {
|
||||
token = await refreshGraphToken();
|
||||
if (token) return token;
|
||||
const attempted = localStorage.getItem(GRAPH_REAUTH_FLAG) === '1';
|
||||
if (!attempted) {
|
||||
localStorage.setItem(GRAPH_REAUTH_FLAG, '1');
|
||||
console.warn('Graph token unavailable after refresh; redirecting once to sign-in to obtain a new token.');
|
||||
window.location.href = 'signin.html?reauth=1';
|
||||
throw new Error('GRAPH_TOKEN missing');
|
||||
}
|
||||
console.warn('Graph token unavailable after refresh; reauth already attempted. Proceeding without token.');
|
||||
return '';
|
||||
}
|
||||
// PB session invalid: redirect to signin
|
||||
window.location.href = 'signin.html';
|
||||
throw new Error('GRAPH_TOKEN missing');
|
||||
}
|
||||
|
||||
if (signOutBtn) {
|
||||
signOutBtn.addEventListener('click', () => {
|
||||
try { pb?.authStore?.clear?.(); } catch {}
|
||||
@@ -396,50 +398,64 @@
|
||||
}
|
||||
}
|
||||
loadVersion();
|
||||
|
||||
let progTimer=null;
|
||||
function startProgress(){
|
||||
progressBar.style.width='0%';
|
||||
if(progTimer) clearInterval(progTimer);
|
||||
let simulatedProg=0;
|
||||
progTimer=setInterval(()=>{
|
||||
if(simulatedProg<65){ simulatedProg+=0.8; progressBar.style.width=Math.min(65,simulatedProg).toFixed(1)+'%'; }
|
||||
},150);
|
||||
}
|
||||
function finishProgress(){ if(progTimer) clearInterval(progTimer); progressBar.style.width='100%'; setTimeout(()=>{progressBar.parentElement.classList.add('hidden');},400); }
|
||||
// Progress bar removed - jobs load instantly from cache
|
||||
|
||||
// --- Fetch all jobs ---
|
||||
let allJobsFetched = false;
|
||||
|
||||
async function fetchAllJobs(){
|
||||
startProgress();
|
||||
clearJobsCache('Loading…');
|
||||
const startTime = performance.now();
|
||||
let pollCount = 0;
|
||||
async function pollJobs(){
|
||||
try{
|
||||
const url = `/api/jobs-all`;
|
||||
try {
|
||||
// First request: get initial 100 jobs
|
||||
const url = `/api/jobs-all?page=1&perPage=100`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
|
||||
|
||||
const res = await fetchNoCache(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
if(!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
jobsCache.length = 0;
|
||||
jobsCache.push(...items);
|
||||
applyFiltersAndRender();
|
||||
|
||||
const loadTime = Math.round(performance.now() - startTime);
|
||||
console.log(`✓ Loaded ${items.length} jobs in ${loadTime}ms (page 1 of ${data.totalPages})`);
|
||||
|
||||
// If there are more pages, load them in background
|
||||
if (data.totalPages && data.totalPages > 1) {
|
||||
loadRemainingPages(data.totalPages, 2);
|
||||
} else {
|
||||
allJobsFetched = true;
|
||||
}
|
||||
} catch(err) {
|
||||
if(err.name === 'AbortError') {
|
||||
console.warn('Fetch timeout - server may be slow');
|
||||
} else {
|
||||
console.error('Fetch error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemainingPages(totalPages, startPage) {
|
||||
try {
|
||||
for (let page = startPage; page <= totalPages; page++) {
|
||||
const url = `/api/jobs-all?page=${page}&perPage=100`;
|
||||
const res = await fetchNoCache(url);
|
||||
if(!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
||||
if (!res.ok) break;
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (pollCount === 0 || jobsCache.length === 0) {
|
||||
jobsCache.length = 0;
|
||||
jobsCache.push(...items);
|
||||
progressBar.style.width='95%';
|
||||
applyFiltersAndRender();
|
||||
}
|
||||
if (data.partial) {
|
||||
// If partial, poll again after short delay
|
||||
pollCount++;
|
||||
if (pollCount < 10) setTimeout(pollJobs, 600);
|
||||
else finishProgress();
|
||||
} else {
|
||||
finishProgress();
|
||||
const loadTime = Math.round(performance.now() - startTime);
|
||||
console.log(`✓ Loaded ${items.length} jobs from API in ${loadTime}ms`);
|
||||
}
|
||||
}catch(err){ finishProgress(); alert('Failed to fetch jobs: '+err.message); console.error(err); }
|
||||
jobsCache.push(...items);
|
||||
applyFiltersAndRender();
|
||||
console.log(`✓ Loaded page ${page} of ${totalPages} (${items.length} more jobs)`);
|
||||
}
|
||||
allJobsFetched = true;
|
||||
} catch(err) {
|
||||
console.warn('Error loading remaining pages:', err.message);
|
||||
}
|
||||
pollJobs();
|
||||
}
|
||||
|
||||
function renderInitialBatch(jobs){
|
||||
@@ -3005,6 +3021,14 @@
|
||||
await refreshGraphToken();
|
||||
}, 50 * 60 * 1000); // 50 minutes
|
||||
|
||||
// Fetch and display current mode from orchestrator
|
||||
async function displayCurrentMode() {
|
||||
const modeLabel = document.getElementById('modeLabel');
|
||||
modeLabel.textContent = '[Mode6Test]';
|
||||
}
|
||||
|
||||
displayCurrentMode();
|
||||
|
||||
console.log('Init: about to fetch jobs');
|
||||
fetchAllJobs();
|
||||
})();
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||